/* * Copyright 2011 Azwan Adli Abdullah * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.gh4a.activities; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.graphics.Point; import android.net.Uri; import android.os.Bundle; import android.support.v4.content.Loader; import android.support.v4.util.LongSparseArray; import android.support.v4.view.MenuItemCompat; import android.support.v7.app.ActionBar; import android.support.v7.app.AlertDialog; import android.support.v7.widget.ListPopupWindow; import android.text.TextUtils; import android.util.SparseArray; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ListAdapter; import android.widget.TextView; import com.gh4a.Gh4Application; import com.gh4a.ProgressDialogTask; import com.gh4a.R; import com.gh4a.loader.LoaderCallbacks; import com.gh4a.loader.LoaderResult; import com.gh4a.utils.ApiHelpers; import com.gh4a.utils.FileUtils; import com.gh4a.utils.IntentUtils; import com.gh4a.utils.StringUtils; import com.gh4a.utils.UiUtils; import org.eclipse.egit.github.core.CommitComment; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; public abstract class DiffViewerActivity extends WebViewerActivity implements View.OnTouchListener { protected static Intent fillInIntent(Intent baseIntent, String repoOwner, String repoName, String commitSha, String path, String diff, List<CommitComment> comments, int initialLine, int highlightStartLine, int highlightEndLine, boolean highlightisRight, IntentUtils.InitialCommentMarker initialComment) { return baseIntent.putExtra("owner", repoOwner) .putExtra("repo", repoName) .putExtra("sha", commitSha) .putExtra("path", path) .putExtra("diff", diff) .putExtra("comments", comments != null ? new ArrayList<>(comments) : null) .putExtra("initial_line", initialLine) .putExtra("highlight_start", highlightStartLine) .putExtra("highlight_end", highlightEndLine) .putExtra("highlight_right", highlightisRight) .putExtra("initial_comment", initialComment); } private static final Pattern HUNK_START_PATTERN = Pattern.compile("@@ -(\\d+),\\d+ \\+(\\d+),\\d+.*"); private static final String COMMENT_ADD_URI_FORMAT = "comment://add?position=%d&l=%d&r=%d"; private static final String COMMENT_EDIT_URI_FORMAT = "comment://edit?position=%d&l=%d&r=%d&id=%d"; protected String mRepoOwner; protected String mRepoName; protected String mPath; protected String mSha; private int mInitialLine; private int mHighlightStartLine; private int mHighlightEndLine; private boolean mHighlightIsRight; private IntentUtils.InitialCommentMarker mInitialComment; private String mDiff; private String[] mDiffLines; private final SparseArray<List<CommitComment>> mCommitCommentsByPos = new SparseArray<>(); private final LongSparseArray<CommitComment> mCommitComments = new LongSparseArray<>(); private final Point mLastTouchDown = new Point(); private static final int MENU_ITEM_VIEW = 10; private final LoaderCallbacks<List<CommitComment>> mCommentCallback = new LoaderCallbacks<List<CommitComment>>(this) { @Override protected Loader<LoaderResult<List<CommitComment>>> onCreateLoader() { return createCommentLoader(); } @Override protected void onResultReady(List<CommitComment> result) { addCommentsToMap(result); onDataReady(); } }; @Override @SuppressWarnings("unchecked") public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ActionBar actionBar = getSupportActionBar(); actionBar.setTitle(FileUtils.getFileName(mPath)); actionBar.setSubtitle(mRepoOwner + "/" + mRepoName); actionBar.setDisplayHomeAsUpEnabled(true); mWebView.setOnTouchListener(this); List<CommitComment> comments = (ArrayList<CommitComment>) getIntent().getSerializableExtra("comments"); if (comments != null) { addCommentsToMap(comments); onDataReady(); } else { getSupportLoaderManager().initLoader(0, null, mCommentCallback); } } @Override protected void onInitExtras(Bundle extras) { super.onInitExtras(extras); mRepoOwner = extras.getString("owner"); mRepoName = extras.getString("repo"); mPath = extras.getString("path"); mSha = extras.getString("sha"); mDiff = extras.getString("diff"); mInitialLine = extras.getInt("initial_line", -1); mHighlightStartLine = extras.getInt("highlight_start", -1); mHighlightEndLine = extras.getInt("highlight_end", -1); mHighlightIsRight = extras.getBoolean("highlight_right", false); mInitialComment = extras.getParcelable("initial_comment"); extras.remove("initial_comment"); } @Override protected boolean canSwipeToRefresh() { // no need for pull-to-refresh if everything was passed in the intent extras return !getIntent().hasExtra("comments"); } @Override public void onRefresh() { if (forceLoaderReload(0)) { mCommitCommentsByPos.clear(); setContentShown(false); } super.onRefresh(); } @Override public boolean onCreateOptionsMenu(Menu menu) { MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.file_viewer_menu, menu); String viewAtTitle = getString(R.string.object_view_file_at, mSha.substring(0, 7)); MenuItem item = menu.add(0, MENU_ITEM_VIEW, Menu.NONE, viewAtTitle); MenuItemCompat.setShowAsAction(item, MenuItemCompat.SHOW_AS_ACTION_NEVER); return super.onCreateOptionsMenu(menu); } @Override protected String generateHtml(String cssTheme, boolean addTitleHeader) { StringBuilder content = new StringBuilder(); boolean authorized = Gh4Application.get().isAuthorized(); String title = addTitleHeader ? getDocumentTitle() : null; content.append("<html><head><title>"); if (title != null) { content.append(title); } content.append("</title>"); writeCssInclude(content, "text", cssTheme); writeScriptInclude(content, "codeutils"); content.append("</head><body"); int highlightInsertPos = content.length(); content.append(">"); if (title != null) { content.append("<h2>").append(title).append("</h2>"); } content.append("<pre>"); mDiffLines = mDiff.split("\n"); int highlightStartLine = -1, highlightEndLine = -1; int leftDiffPosition = -1, rightDiffPosition = -1; for (int i = 0; i < mDiffLines.length; i++) { String line = mDiffLines[i]; String cssClass = null; if (line.startsWith("@@")) { Matcher matcher = HUNK_START_PATTERN.matcher(line); if (matcher.matches()) { leftDiffPosition = Integer.parseInt(matcher.group(1)) - 1; rightDiffPosition = Integer.parseInt(matcher.group(2)) - 1; } cssClass = "change"; } else if (line.startsWith("+")) { ++rightDiffPosition; cssClass = "add"; } else if (line.startsWith("-")) { ++leftDiffPosition; cssClass = "remove"; } else { ++leftDiffPosition; ++rightDiffPosition; } int pos = mHighlightIsRight ? rightDiffPosition : leftDiffPosition; if (pos != -1 && pos == mHighlightStartLine) { highlightStartLine = i; } if (pos != -1 && pos == mHighlightEndLine) { highlightEndLine = i; } content.append("<div id=\"line").append(i).append("\""); if (cssClass != null) { content.append("class=\"").append(cssClass).append("\""); } if (authorized) { String uri = String.format(Locale.US, COMMENT_ADD_URI_FORMAT, i, leftDiffPosition, rightDiffPosition); content.append(" onclick=\"javascript:location.href='"); content.append(uri).append("'\""); } content.append(">").append(TextUtils.htmlEncode(line)).append("</div>"); List<CommitComment> comments = mCommitCommentsByPos.get(i); if (comments != null) { for (CommitComment comment : comments) { long id = comment.getId(); mCommitComments.put(id, comment); content.append("<div ").append("id=\"comment").append(id).append("\""); content.append(" class=\"comment"); if (mInitialComment != null && mInitialComment.matches(id, null)) { content.append(" highlighted"); } content.append("\""); if (authorized) { String uri = String.format(Locale.US, COMMENT_EDIT_URI_FORMAT, i, leftDiffPosition, rightDiffPosition, id); content.append(" onclick=\"javascript:location.href='"); content.append(uri).append("'\""); } content.append("><div class=\"change\">"); content.append(getString(R.string.commit_comment_header, "<b>" + ApiHelpers.getUserLogin(this, comment.getUser()) + "</b>", StringUtils.formatRelativeTime(DiffViewerActivity.this, comment.getCreatedAt(), true))); content.append("</div>").append(comment.getBodyHtml()).append("</div>"); } } } if (mInitialLine > 0) { content.insert(highlightInsertPos, " onload='scrollToElement(\"line" + mInitialLine + "\")' onresize='scrollToHighlight();'"); } else if (mInitialComment != null) { content.insert(highlightInsertPos, " onload='scrollToElement(\"comment" + mInitialComment.commentId + "\")' onresize='scrollToHighlight();'"); } else if (highlightStartLine != -1 && highlightEndLine != -1) { content.insert(highlightInsertPos, " onload='highlightDiffLines(" + highlightStartLine + "," + highlightEndLine + ")' onresize='scrollToHighlight();'"); } content.append("</pre></body></html>"); return content.toString(); } @Override protected String getDocumentTitle() { return getString(R.string.diff_print_document_title, FileUtils.getFileName(mPath), mSha.substring(0, 7), mRepoOwner, mRepoName); } @Override public boolean onOptionsItemSelected(MenuItem item) { String url = createUrl(); switch (item.getItemId()) { case R.id.browser: IntentUtils.launchBrowser(this, Uri.parse(url)); return true; case R.id.share: Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("text/plain"); shareIntent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.share_commit_subject, mSha.substring(0, 7), mRepoOwner + "/" + mRepoName)); shareIntent.putExtra(Intent.EXTRA_TEXT, url); shareIntent = Intent.createChooser(shareIntent, getString(R.string.share_title)); startActivity(shareIntent); return true; case MENU_ITEM_VIEW: startActivity(FileViewerActivity.makeIntent(this, mRepoOwner, mRepoName, mSha, mPath)); return true; } return super.onOptionsItemSelected(item); } private void addCommentsToMap(List<CommitComment> comments) { for (CommitComment comment : comments) { if (!TextUtils.equals(comment.getPath(), mPath)) { continue; } int position = comment.getPosition(); List<CommitComment> commentsByPos = mCommitCommentsByPos.get(position); if (commentsByPos == null) { commentsByPos = new ArrayList<>(); mCommitCommentsByPos.put(position, commentsByPos); } commentsByPos.add(comment); } } private void openCommentDialog(final long id, final long replyToId, String line, final int position, final int leftLine, final int rightLine) { final boolean isEdit = id != 0L; LayoutInflater inflater = LayoutInflater.from(this); View commentDialog = inflater.inflate(R.layout.commit_comment_dialog, null); final TextView code = (TextView) commentDialog.findViewById(R.id.line); code.setText(line); final EditText body = (EditText) commentDialog.findViewById(R.id.body); if (isEdit) { body.setText(mCommitComments.get(id).getBody()); } final int saveButtonResId = isEdit ? R.string.issue_comment_update_title : R.string.issue_comment_title; final DialogInterface.OnClickListener saveCb = new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int which) { String text = body.getText().toString(); new CommentTask(id, replyToId, text, position).schedule(); } }; AlertDialog d = new AlertDialog.Builder(this) .setCancelable(true) .setTitle(getString(R.string.commit_comment_dialog_title, leftLine, rightLine)) .setView(commentDialog) .setPositiveButton(saveButtonResId, saveCb) .setNegativeButton(R.string.cancel, null) .show(); body.addTextChangedListener(new UiUtils.ButtonEnableTextWatcher( body, d.getButton(DialogInterface.BUTTON_POSITIVE))); } @Override protected void handleUrlLoad(Uri uri) { if (!uri.getScheme().equals("comment")) { super.handleUrlLoad(uri); return; } int line = Integer.parseInt(uri.getQueryParameter("position")); int leftLine = Integer.parseInt(uri.getQueryParameter("l")); int rightLine = Integer.parseInt(uri.getQueryParameter("r")); String lineText = mDiffLines[line]; String idParam = uri.getQueryParameter("id"); long id = idParam != null ? Long.parseLong(idParam) : 0L; if (idParam == null) { openCommentDialog(id, 0L, lineText, line, leftLine, rightLine); } else { CommentActionPopup p = new CommentActionPopup(id, line, lineText, leftLine, rightLine, mLastTouchDown.x, mLastTouchDown.y); p.show(); } } @Override public boolean onTouch(View view, MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_DOWN) { mLastTouchDown.set((int) event.getX(), (int) event.getY()); } return false; } private void refresh() { mCommitComments.clear(); mCommitCommentsByPos.clear(); getSupportLoaderManager().restartLoader(0, null, mCommentCallback); setContentShown(false); } protected abstract Loader<LoaderResult<List<CommitComment>>> createCommentLoader(); protected abstract void createComment(CommitComment comment, long replyToCommentId) throws IOException; protected abstract void editComment(CommitComment comment) throws IOException; protected abstract void deleteComment(long id) throws IOException; protected abstract boolean canReply(); protected abstract String createUrl(); private class CommentActionPopup extends ListPopupWindow implements AdapterView.OnItemClickListener { private final long mId; private final int mPosition; private final int mLeftLine; private final int mRightLine; private final String mLineText; public CommentActionPopup(long id, int position, String lineText, int leftLine, int rightLine, int x, int y) { super(DiffViewerActivity.this, null, R.attr.listPopupWindowStyle); mId = id; mPosition = position; mLeftLine = leftLine; mRightLine = rightLine; mLineText = lineText; ArrayAdapter<String> adapter = new ArrayAdapter<>(DiffViewerActivity.this, R.layout.popup_menu_item, populateChoices(isOwnComment(id))); setAdapter(adapter); setContentWidth(measureContentWidth(adapter)); View anchor = findViewById(R.id.popup_helper); anchor.layout(x, y, x + 1, y + 1); setAnchorView(anchor); setOnItemClickListener(this); setModal(true); } @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { int choiceCount = parent.getAdapter().getCount(); if (choiceCount > 2 && position == choiceCount - 1) { new AlertDialog.Builder(DiffViewerActivity.this) .setMessage(R.string.delete_comment_message) .setPositiveButton(R.string.delete, new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int whichButton) { new DeleteCommentTask(mId).schedule(); } }) .setNegativeButton(R.string.cancel, null) .show(); } else { long replyToId = canReply() && position == 0 ? mId : 0L; long commentId = choiceCount > 2 && position == choiceCount - 2 ? mId : 0L; openCommentDialog(commentId, replyToId, mLineText, mPosition, mLeftLine, mRightLine); } dismiss(); } private boolean isOwnComment(long id) { String login = Gh4Application.get().getAuthLogin(); CommitComment comment = mCommitComments.get(id); return ApiHelpers.loginEquals(comment.getUser(), login); } private String[] populateChoices(boolean ownComment) { int choiceCount = (ownComment ? 3 : 1) + (canReply() ? 1 : 0); String[] choices = new String[choiceCount]; int index = 0; if (canReply()) { choices[index++] = getString(R.string.reply); } choices[index++] = getString(R.string.add_comment); if (ownComment) { choices[index++] = getString(R.string.edit); choices[index++] = getString(R.string.delete); } return choices; } private int measureContentWidth(ListAdapter adapter) { Context context = DiffViewerActivity.this; ViewGroup measureParent = new FrameLayout(context); int measureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); int maxWidth = 0, count = adapter.getCount(); View itemView = null; for (int i = 0; i < count; i++) { itemView = adapter.getView(i, itemView, measureParent); itemView.measure(measureSpec, measureSpec); maxWidth = Math.max(maxWidth, itemView.getMeasuredWidth()); } return maxWidth; } } private class CommentTask extends ProgressDialogTask<Void> { private final CommitComment mComment; private final long mReplyToId; public CommentTask(long id, long replyToId, String body, int position) { super(DiffViewerActivity.this, R.string.saving_msg); mComment = new CommitComment(); mComment.setBody(body); mComment.setId(id); mComment.setPosition(position); mReplyToId = replyToId; } @Override protected ProgressDialogTask<Void> clone() { return new CommentTask(mComment.getId(), mReplyToId, mComment.getBody(), mComment.getPosition()); } @Override protected Void run() throws IOException { if (mComment.getId() == 0L) { createComment(mComment, mReplyToId); } else { editComment(mComment); } return null; } @Override protected void onSuccess(Void result) { refresh(); setResult(RESULT_OK); } @Override protected String getErrorMessage() { return getContext().getString(R.string.error_edit_commit_comment, mComment.getPosition()); } } private class DeleteCommentTask extends ProgressDialogTask<Void> { private final long mId; public DeleteCommentTask(long id) { super(DiffViewerActivity.this, R.string.deleting_msg); mId = id; } @Override protected ProgressDialogTask<Void> clone() { return new DeleteCommentTask(mId); } @Override protected Void run() throws IOException { deleteComment(mId); return null; } @Override protected void onSuccess(Void result) { refresh(); setResult(RESULT_OK); } @Override protected String getErrorMessage() { return getContext().getString(R.string.error_delete_commit_comment); } } }