package org.wikipedia.edit;
import android.app.ProgressDialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.graphics.Typeface;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v4.content.ContextCompat;
import android.support.v7.app.ActionBar;
import android.support.v7.app.AlertDialog;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.EditText;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import org.wikipedia.Constants;
import org.wikipedia.R;
import org.wikipedia.WikipediaApp;
import org.wikipedia.activity.ThemedActionBarActivity;
import org.wikipedia.analytics.EditFunnel;
import org.wikipedia.analytics.LoginFunnel;
import org.wikipedia.captcha.CaptchaHandler;
import org.wikipedia.captcha.CaptchaResult;
import org.wikipedia.csrf.CsrfTokenClient;
import org.wikipedia.dataclient.mwapi.MwException;
import org.wikipedia.dataclient.mwapi.MwQueryResponse;
import org.wikipedia.edit.preview.EditPreviewFragment;
import org.wikipedia.edit.richtext.SyntaxHighlighter;
import org.wikipedia.edit.summaries.EditSummaryFragment;
import org.wikipedia.edit.wikitext.Wikitext;
import org.wikipedia.edit.wikitext.WikitextClient;
import org.wikipedia.login.LoginActivity;
import org.wikipedia.login.LoginClient;
import org.wikipedia.login.User;
import org.wikipedia.page.LinkMovementMethodExt;
import org.wikipedia.page.PageProperties;
import org.wikipedia.page.PageTitle;
import org.wikipedia.util.FeedbackUtil;
import org.wikipedia.util.StringUtil;
import org.wikipedia.util.log.L;
import org.wikipedia.views.ViewAnimations;
import org.wikipedia.views.WikiErrorView;
import java.util.concurrent.TimeUnit;
import retrofit2.Call;
import static org.wikipedia.util.DeviceUtil.hideSoftKeyboard;
import static org.wikipedia.util.L10nUtil.setConditionalTextDirection;
import static org.wikipedia.util.UriUtil.handleExternalLink;
public class EditSectionActivity extends ThemedActionBarActivity {
public static final String ACTION_EDIT_SECTION = "org.wikipedia.edit_section";
public static final String EXTRA_TITLE = "org.wikipedia.edit_section.title";
public static final String EXTRA_SECTION_ID = "org.wikipedia.edit_section.sectionid";
public static final String EXTRA_SECTION_HEADING = "org.wikipedia.edit_section.sectionheading";
public static final String EXTRA_PAGE_PROPS = "org.wikipedia.edit_section.pageprops";
public static final String EXTRA_HIGHLIGHT_TEXT = "org.wikipedia.edit_section.highlight";
private CsrfTokenClient csrfClient;
private PageTitle title;
private int sectionID;
private String sectionHeading;
private PageProperties pageProps;
private String textToHighlight;
private String sectionWikitext;
private SyntaxHighlighter syntaxHighlighter;
private EditText sectionText;
private boolean sectionTextModified = false;
private boolean sectionTextFirstLoad = true;
private View sectionProgress;
private ScrollView sectionContainer;
private WikiErrorView errorView;
private View abusefilterContainer;
private ImageView abuseFilterImage;
private TextView abusefilterTitle;
private TextView abusefilterText;
private EditAbuseFilterResult abusefilterEditResult;
private CaptchaHandler captchaHandler;
private EditPreviewFragment editPreviewFragment;
private EditSummaryFragment editSummaryFragment;
private EditFunnel funnel;
private ProgressDialog progressDialog;
private Runnable successRunnable = new Runnable() {
@Override public void run() {
progressDialog.dismiss();
//Build intent that includes the section we were editing, so we can scroll to it later
Intent data = new Intent();
data.putExtra(EXTRA_SECTION_ID, sectionID);
setResult(EditHandler.RESULT_REFRESH_PAGE, data);
hideSoftKeyboard(EditSectionActivity.this);
finish();
}
};
public PageTitle getPageTitle() {
return title;
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_edit_section);
if (!getIntent().getAction().equals(ACTION_EDIT_SECTION)) {
throw new RuntimeException("Much wrong action. Such exception. Wow");
}
title = getIntent().getParcelableExtra(EXTRA_TITLE);
sectionID = getIntent().getIntExtra(EXTRA_SECTION_ID, 0);
sectionHeading = getIntent().getStringExtra(EXTRA_SECTION_HEADING);
pageProps = getIntent().getParcelableExtra(EXTRA_PAGE_PROPS);
textToHighlight = getIntent().getStringExtra(EXTRA_HIGHLIGHT_TEXT);
progressDialog = new ProgressDialog(this);
progressDialog.setIndeterminate(true);
progressDialog.setCancelable(false);
progressDialog.setMessage(getString(R.string.dialog_saving_in_progress));
final ActionBar supportActionBar = getSupportActionBar();
if (supportActionBar != null) {
supportActionBar.setTitle("");
}
sectionText = (EditText) findViewById(R.id.edit_section_text);
syntaxHighlighter = new SyntaxHighlighter(this, sectionText);
sectionProgress = findViewById(R.id.edit_section_load_progress);
sectionContainer = (ScrollView) findViewById(R.id.edit_section_container);
sectionContainer.setSmoothScrollingEnabled(false);
errorView = (WikiErrorView) findViewById(R.id.view_edit_section_error);
abusefilterContainer = findViewById(R.id.edit_section_abusefilter_container);
abuseFilterImage = (ImageView) findViewById(R.id.edit_section_abusefilter_image);
abusefilterTitle = (TextView) findViewById(R.id.edit_section_abusefilter_title);
abusefilterText = (TextView) findViewById(R.id.edit_section_abusefilter_text);
captchaHandler = new CaptchaHandler(this, title.getWikiSite(), progressDialog, sectionContainer, "", null);
editPreviewFragment = (EditPreviewFragment) getSupportFragmentManager().findFragmentById(R.id.edit_section_preview_fragment);
editSummaryFragment = (EditSummaryFragment) getSupportFragmentManager().findFragmentById(R.id.edit_section_summary_fragment);
updateEditLicenseText();
editSummaryFragment.setTitle(title);
funnel = WikipediaApp.getInstance().getFunnelManager().getEditFunnel(title);
// Only send the editing start log event if the activity is created for the first time
if (savedInstanceState == null) {
funnel.logStart();
}
if (savedInstanceState != null && savedInstanceState.containsKey("sectionWikitext")) {
sectionWikitext = savedInstanceState.getString("sectionWikitext");
}
captchaHandler.restoreState(savedInstanceState);
if (savedInstanceState != null && savedInstanceState.containsKey("abusefilter")) {
abusefilterEditResult = savedInstanceState.getParcelable("abusefilter");
handleAbuseFilter();
}
errorView.setRetryClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
errorView.setVisibility(View.GONE);
fetchSectionText();
}
});
errorView.setBackClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
onBackPressed();
}
});
setConditionalTextDirection(sectionText, title.getWikiSite().languageCode());
fetchSectionText();
if (savedInstanceState != null && savedInstanceState.containsKey("sectionTextModified")) {
sectionTextModified = savedInstanceState.getBoolean("sectionTextModified");
}
sectionText.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence charSequence, int start, int count, int after) {
}
@Override
public void onTextChanged(CharSequence charSequence, int start, int before, int count) {
}
@Override
public void afterTextChanged(Editable editable) {
if (sectionTextFirstLoad) {
sectionTextFirstLoad = false;
return;
}
if (!sectionTextModified) {
sectionTextModified = true;
// update the actionbar menu, which will enable the Next button.
supportInvalidateOptionsMenu();
}
}
});
// set focus to the EditText, but keep the keyboard hidden until the user changes the cursor location:
sectionText.requestFocus();
getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN);
}
@Override
public void onDestroy() {
cancelCalls();
if (progressDialog.isShowing()) {
progressDialog.dismiss();
}
syntaxHighlighter.cleanup();
super.onDestroy();
}
@Override
protected void setTheme() {
setActionBarTheme();
}
private void updateEditLicenseText() {
TextView editLicenseText = (TextView) findViewById(R.id.edit_section_license_text);
editLicenseText.setText(StringUtil.fromHtml(String.format(getString(User.isLoggedIn()
? R.string.edit_save_action_license_logged_in
: R.string.edit_save_action_license_anon),
getString(R.string.terms_of_use_url),
getString(R.string.cc_by_sa_3_url))));
editLicenseText.setMovementMethod(new LinkMovementMethodExt(new LinkMovementMethodExt.UrlHandler() {
@Override
public void onUrlClick(@NonNull String url, @Nullable String notUsed) {
if (url.equals("https://#login")) {
funnel.logLoginAttempt();
Intent loginIntent = LoginActivity.newIntent(EditSectionActivity.this,
LoginFunnel.SOURCE_EDIT, funnel.getSessionToken());
startActivityForResult(loginIntent, Constants.ACTIVITY_REQUEST_LOGIN);
} else {
handleExternalLink(EditSectionActivity.this, Uri.parse(url));
}
}
}));
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == Constants.ACTIVITY_REQUEST_LOGIN) {
if (resultCode == LoginActivity.RESULT_LOGIN_SUCCESS) {
updateEditLicenseText();
funnel.logLoginSuccess();
FeedbackUtil.showMessage(this, R.string.login_success_toast);
} else {
funnel.logLoginFailure();
}
}
}
private void cancelCalls() {
if (csrfClient != null) {
csrfClient.cancel();
csrfClient = null;
}
}
private void getEditTokenThenSave(boolean forceLogin) {
cancelCalls();
captchaHandler.hideCaptcha();
editSummaryFragment.saveSummary();
csrfClient = new CsrfTokenClient(title.getWikiSite(), title.getWikiSite());
csrfClient.request(forceLogin, new CsrfTokenClient.Callback() {
@Override
public void success(@NonNull String token) {
doSave(token);
}
@Override
public void failure(@NonNull Throwable caught) {
showError(caught);
}
@Override
public void twoFactorPrompt() {
showError(new LoginClient.LoginFailedException(getResources()
.getString(R.string.login_2fa_other_workflow_error_msg)));
}
});
}
private void doSave(@NonNull String token) {
String summaryText = TextUtils.isEmpty(sectionHeading) ? "" : ("/* " + sectionHeading + " */ ");
summaryText += editPreviewFragment.getSummary();
// Summaries are plaintext, so remove any HTML that's made its way into the summary
summaryText = StringUtil.fromHtml(summaryText).toString();
if (!isFinishing()) {
progressDialog.show();
}
new EditClient().request(title.getWikiSite(), title, sectionID,
sectionText.getText().toString(), token, summaryText, User.isLoggedIn(),
captchaHandler.isActive() ? captchaHandler.captchaId() : "null",
captchaHandler.isActive() ? captchaHandler.captchaWord() : "null",
new EditClient.Callback() {
@Override
public void success(@NonNull Call<Edit> call, @NonNull EditResult result) {
if (isFinishing() || !progressDialog.isShowing()) {
// no longer attached to activity!
return;
}
if (result instanceof EditSuccessResult) {
funnel.logSaved(((EditSuccessResult) result).getRevID());
// TODO: remove the artificial delay and use the new revision
// ID returned to request the updated version of the page once
// revision support for mobile-sections is added to RESTBase
// See https://github.com/wikimedia/restbase/pull/729
new Handler().postDelayed(successRunnable, TimeUnit.SECONDS.toMillis(2));
} else if (result instanceof CaptchaResult) {
if (captchaHandler.isActive()) {
// Captcha entry failed!
funnel.logCaptchaFailure();
}
captchaHandler.handleCaptcha(null, (CaptchaResult) result);
funnel.logCaptchaShown();
} else if (result instanceof EditAbuseFilterResult) {
abusefilterEditResult = (EditAbuseFilterResult) result;
handleAbuseFilter();
if (abusefilterEditResult.getType() == EditAbuseFilterResult.TYPE_ERROR) {
editPreviewFragment.hide();
}
} else if (result instanceof EditSpamBlacklistResult) {
FeedbackUtil.showMessage(EditSectionActivity.this,
R.string.editing_error_spamblacklist);
progressDialog.dismiss();
editPreviewFragment.hide();
} else {
funnel.logError(result.getResult());
// Expand to do everything.
failure(call, new Throwable());
}
}
@Override
public void failure(@NonNull Call<Edit> call, @NonNull Throwable caught) {
if (isFinishing() || !progressDialog.isShowing()) {
// no longer attached to activity!
return;
}
if (caught instanceof MwException) {
handleEditingException((MwException) caught);
L.e(caught);
} else {
showRetryDialog(caught);
L.e(caught);
}
}
});
}
private void showRetryDialog(@NonNull Throwable t) {
final AlertDialog retryDialog = new AlertDialog.Builder(EditSectionActivity.this)
.setTitle(R.string.dialog_message_edit_failed)
.setMessage(t.getLocalizedMessage())
.setPositiveButton(R.string.dialog_message_edit_failed_retry, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
getEditTokenThenSave(false);
dialog.dismiss();
progressDialog.dismiss();
}
})
.setNegativeButton(R.string.dialog_message_edit_failed_cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
dialog.dismiss();
progressDialog.dismiss();
}
}).create();
retryDialog.show();
}
/**
* Processes API error codes encountered during editing, and handles them as appropriate.
* @param caught The MwException to handle.
*/
private void handleEditingException(@NonNull MwException caught) {
String code = caught.getTitle();
if (User.isLoggedIn() && ("badtoken".equals(code) || "assertuserfailed".equals(code))) {
getEditTokenThenSave(true);
} else if ("blocked".equals(code) || "wikimedia-globalblocking-ipblocked".equals(code)) {
// User is blocked, locally or globally
// If they were anon, canedit does not catch this, so we can't show them the locked pencil
// If they not anon, this means they were blocked in the interim between opening the edit
// window and clicking save. Less common, but might as well handle it
progressDialog.dismiss();
AlertDialog.Builder builder = new AlertDialog.Builder(EditSectionActivity.this);
builder.setTitle(R.string.user_blocked_from_editing_title);
if (User.isLoggedIn()) {
builder.setMessage(R.string.user_logged_in_blocked_from_editing);
builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
}
});
} else {
builder.setMessage(R.string.user_anon_blocked_from_editing);
builder.setPositiveButton(R.string.nav_item_login, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
Intent loginIntent = LoginActivity.newIntent(EditSectionActivity.this,
LoginFunnel.SOURCE_BLOCKED);
startActivity(loginIntent);
}
});
builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
}
});
}
builder.show();
} else {
progressDialog.dismiss();
showError(caught);
}
}
private void handleAbuseFilter() {
if (abusefilterEditResult == null) {
return;
}
if (abusefilterEditResult.getType() == EditAbuseFilterResult.TYPE_ERROR) {
funnel.logAbuseFilterError(abusefilterEditResult.getCode());
abuseFilterImage.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_abusefilter_disallow));
abusefilterTitle.setText(getString(R.string.abusefilter_title_disallow));
abusefilterText.setText(StringUtil.fromHtml(getString(R.string.abusefilter_text_disallow)));
} else {
funnel.logAbuseFilterWarning(abusefilterEditResult.getCode());
abuseFilterImage.setImageDrawable(ContextCompat.getDrawable(this, R.drawable.ic_abusefilter_warn));
abusefilterTitle.setText(getString(R.string.abusefilter_title_warn));
abusefilterText.setText(StringUtil.fromHtml(getString(R.string.abusefilter_text_warn)));
}
hideSoftKeyboard(this);
ViewAnimations.fadeIn(abusefilterContainer, new Runnable() {
@Override
public void run() {
supportInvalidateOptionsMenu();
}
});
if (progressDialog.isShowing()) {
progressDialog.dismiss();
}
}
private void cancelAbuseFilter() {
abusefilterEditResult = null;
ViewAnimations.fadeOut(abusefilterContainer, new Runnable() {
@Override
public void run() {
supportInvalidateOptionsMenu();
}
});
}
/**
* Executes a click of the actionbar button, and performs the appropriate action
* based on the current state of the button.
*/
public void clickNextButton() {
if (editSummaryFragment.isActive()) {
//we're showing the custom edit summary window, so close it and
//apply the provided summary.
editSummaryFragment.hide();
editPreviewFragment.setCustomSummary(editSummaryFragment.getSummary());
} else if (editPreviewFragment.isActive()) {
//we're showing the Preview window, which means that the next step is to save it!
if (abusefilterEditResult != null) {
//if the user was already shown an AbuseFilter warning, and they're ignoring it:
funnel.logAbuseFilterWarningIgnore(abusefilterEditResult.getCode());
}
getEditTokenThenSave(false);
funnel.logSaveAttempt();
} else {
//we must be showing the editing window, so show the Preview.
hideSoftKeyboard(this);
editPreviewFragment.showPreview(title, sectionText.getText().toString());
funnel.logPreview();
}
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_save_section:
clickNextButton();
return true;
default:
return super.onOptionsItemSelected(item);
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
getMenuInflater().inflate(R.menu.menu_edit_section, menu);
MenuItem item = menu.findItem(R.id.menu_save_section);
if (editSummaryFragment.isActive()) {
item.setTitle(getString(R.string.edit_next));
} else if (editPreviewFragment.isActive()) {
item.setTitle(getString(R.string.edit_done));
} else {
item.setTitle(getString(R.string.edit_next));
}
if (abusefilterEditResult != null) {
if (abusefilterEditResult.getType() == EditAbuseFilterResult.TYPE_ERROR) {
item.setEnabled(false);
} else {
item.setEnabled(true);
}
} else {
item.setEnabled(sectionTextModified);
}
View v = getLayoutInflater().inflate(R.layout.item_edit_actionbar_button, null);
item.setActionView(v);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.MATCH_PARENT);
v.setLayoutParams(params);
TextView txtView = (TextView) v.findViewById(R.id.edit_actionbar_button_text);
txtView.setText(item.getTitle());
txtView.setTypeface(null, item.isEnabled() ? Typeface.BOLD : Typeface.NORMAL);
v.setTag(item);
v.setClickable(true);
v.setEnabled(item.isEnabled());
v.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
onOptionsItemSelected((MenuItem) view.getTag());
}
});
if (editSummaryFragment.isActive()) {
v.setBackgroundResource(R.drawable.button_selector_progressive);
} else if (editPreviewFragment.isActive()) {
v.setBackgroundResource(R.drawable.button_selector_complete);
} else {
v.setBackgroundResource(R.drawable.button_selector_progressive);
}
return true;
}
public void showError(@Nullable Throwable caught) {
errorView.setError(caught);
errorView.setVisibility(View.VISIBLE);
}
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.putString("sectionWikitext", sectionWikitext);
outState.putParcelable("abusefilter", abusefilterEditResult);
outState.putBoolean("sectionTextModified", sectionTextModified);
captchaHandler.saveState(outState);
}
private void fetchSectionText() {
if (sectionWikitext == null) {
new WikitextClient().request(title.getWikiSite(), title, sectionID, new WikitextClient.Callback() {
@Override
public void success(@NonNull Call<MwQueryResponse<Wikitext>> call, @NonNull String wikitext) {
sectionWikitext = wikitext;
displaySectionText();
}
@Override
public void failure(@NonNull Call<MwQueryResponse<Wikitext>> call, @NonNull Throwable caught) {
sectionProgress.setVisibility(View.GONE);
showError(caught);
L.e(caught);
}
});
} else {
displaySectionText();
}
}
private void displaySectionText() {
sectionText.setText(sectionWikitext);
ViewAnimations.crossFade(sectionProgress, sectionContainer);
supportInvalidateOptionsMenu();
scrollToHighlight(textToHighlight);
if (pageProps != null && pageProps.getEditProtectionStatus() != null) {
String message;
switch (pageProps.getEditProtectionStatus()) {
case "sysop":
message = getString(R.string.page_protected_sysop);
break;
case "autoconfirmed":
message = getString(R.string.page_protected_autoconfirmed);
break;
default:
message = getString(R.string.page_protected_other, pageProps.getEditProtectionStatus());
break;
}
FeedbackUtil.showMessage(this, message);
}
}
private void scrollToHighlight(@Nullable final String highlightText) {
if (highlightText == null || !TextUtils.isGraphic(highlightText)) {
return;
}
sectionText.post(new Runnable() {
@Override
public void run() {
sectionContainer.fullScroll(View.FOCUS_DOWN);
final int scrollDelayMs = 500;
sectionText.postDelayed(new Runnable() {
@Override
public void run() {
setHighlight(highlightText);
}
}, scrollDelayMs);
}
});
}
private void setHighlight(@NonNull String highlightText) {
String[] words = highlightText.split("\\s+");
int pos = 0;
for (String word : words) {
pos = sectionWikitext.indexOf(word, pos);
if (pos == -1) {
break;
}
}
if (pos == -1) {
pos = sectionWikitext.indexOf(words[words.length - 1]);
}
if (pos > 0) {
// TODO: Programmatic selection doesn't seem to work with RTL content...
sectionText.setSelection(pos, pos + words[words.length - 1].length());
sectionText.performLongClick();
}
}
/**
* Shows the custom edit summary input fragment, where the user may enter a summary
* that's different from the standard summary tags.
*/
public void showCustomSummary() {
editSummaryFragment.show();
}
@Override
public void onBackPressed() {
if (captchaHandler.isActive()) {
captchaHandler.cancelCaptcha();
}
if (abusefilterEditResult != null) {
if (abusefilterEditResult.getType() == EditAbuseFilterResult.TYPE_WARNING) {
funnel.logAbuseFilterWarningBack(abusefilterEditResult.getCode());
}
cancelAbuseFilter();
return;
}
if (editSummaryFragment.handleBackPressed()) {
return;
}
if (editPreviewFragment.handleBackPressed()) {
return;
}
hideSoftKeyboard(this);
if (sectionTextModified) {
AlertDialog.Builder alert = new AlertDialog.Builder(this);
alert.setMessage(getString(R.string.edit_abandon_confirm));
alert.setPositiveButton(getString(R.string.yes), new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int id) {
dialog.dismiss();
finish();
}
});
alert.setNegativeButton(getString(R.string.no), new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int id) {
dialog.dismiss();
}
});
alert.create().show();
} else {
finish();
}
}
}