package com.door43.translationstudio.newui.publish; import android.app.AlertDialog; import android.app.Dialog; import android.app.Fragment; import android.app.FragmentTransaction; import android.content.Context; import android.content.Intent; import android.graphics.Typeface; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.support.design.widget.Snackbar; import android.support.v4.content.FileProvider; import android.text.SpannableString; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.text.method.ScrollingMovementMethod; import android.text.style.ClickableSpan; import android.text.style.StyleSpan; import android.text.style.UnderlineSpan; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.Scroller; import android.widget.TextView; import com.door43.tools.reporting.Logger; import com.door43.translationstudio.R; import com.door43.translationstudio.SettingsActivity; import com.door43.translationstudio.core.Profile; import com.door43.translationstudio.core.Project; import com.door43.translationstudio.core.TargetTranslation; import com.door43.translationstudio.core.TranslationViewMode; import com.door43.translationstudio.dialogs.CustomAlertDialog; import com.door43.translationstudio.newui.Door43LoginDialog; import com.door43.translationstudio.newui.FeedbackDialog; import com.door43.translationstudio.newui.MergeConflictsDialog; import com.door43.translationstudio.newui.translate.TargetTranslationActivity; import com.door43.translationstudio.tasks.CreateRepositoryTask; import com.door43.translationstudio.tasks.PullTargetTranslationTask; import com.door43.translationstudio.tasks.PushTargetTranslationTask; import com.door43.translationstudio.tasks.RegisterSSHKeysTask; import com.door43.translationstudio.AppContext; import com.door43.util.tasks.GenericTaskWatcher; import com.door43.util.tasks.ManagedTask; import com.door43.util.tasks.TaskManager; import com.door43.widget.ViewUtil; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.merge.MergeStrategy; import org.sufficientlysecure.htmltextview.HtmlTextView; import java.io.File; import java.security.InvalidParameterException; import java.util.Map; /** * Created by joel on 9/20/2015. */ public class PublishFragment extends PublishStepFragment implements GenericTaskWatcher.OnFinishedListener { private static final String STATE_UPLOADED = "state_uploaded"; private boolean mUploaded = false; private Button mUploadButton; private GenericTaskWatcher taskWatcher; private LinearLayout mUploadSuccess; private TargetTranslation targetTranslation; public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.fragment_publish_publish, container, false); HtmlTextView explanationView = (HtmlTextView)v.findViewById(R.id.explanation); explanationView.setHtmlFromString(getResources().getString(R.string.publishing_explanation), true); if(savedInstanceState != null) { mUploaded = savedInstanceState.getBoolean(STATE_UPLOADED, false); } Bundle args = getArguments(); final String targetTranslationId = args.getString(PublishActivity.EXTRA_TARGET_TRANSLATION_ID); if (targetTranslationId == null) { throw new InvalidParameterException("a valid target translation id is required"); } taskWatcher = new GenericTaskWatcher(getActivity(), R.string.uploading); taskWatcher.setOnFinishedListener(this); // receive uploaded status from activity (overrides save state from fragment) if(savedInstanceState == null) { mUploaded = args.getBoolean(ARG_PUBLISH_FINISHED, mUploaded); } this.targetTranslation = AppContext.getTranslator().getTargetTranslation(targetTranslationId); mUploadSuccess = (LinearLayout)v.findViewById(R.id.upload_success); mUploadButton = (Button)v.findViewById(R.id.upload_button); if(mUploaded) { mUploadButton.setVisibility(View.GONE); mUploadSuccess.setVisibility(View.VISIBLE); } else { mUploadButton.setVisibility(View.VISIBLE); mUploadSuccess.setVisibility(View.GONE); } // give the user some happy feedback in case they feel like clicking again mUploadSuccess.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Snackbar snack = Snackbar.make(getActivity().findViewById(android.R.id.content), R.string.success, Snackbar.LENGTH_SHORT); ViewUtil.setSnackBarTextColor(snack, getResources().getColor(R.color.light_primary_text)); snack.show(); } }); mUploadButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if(AppContext.context().isNetworkAvailable()) { // make sure we have a gogs user if(AppContext.getProfile().gogsUser == null) { FragmentTransaction ft = getFragmentManager().beginTransaction(); Door43LoginDialog dialog = new Door43LoginDialog(); dialog.show(ft, Door43LoginDialog.TAG); return; } // TODO: 5/26/16 this would be a lot easier if we tried to clone instead of pulling. Then we could merge manually PullTargetTranslationTask task = new PullTargetTranslationTask(targetTranslation); taskWatcher.watch(task); TaskManager.addTask(task, PullTargetTranslationTask.TASK_ID); } else { Snackbar snack = Snackbar.make(getActivity().findViewById(android.R.id.content), R.string.internet_not_available, Snackbar.LENGTH_LONG); ViewUtil.setSnackBarTextColor(snack, getResources().getColor(R.color.light_primary_text)); snack.show(); } } }); ImageView wifiIcon = (ImageView)v.findViewById(R.id.wifi_icon); ViewUtil.tintViewDrawable(wifiIcon, getResources().getColor(R.color.dark_secondary_text)); final String filename = targetTranslation.getId() + ".zip"; // export buttons Button exportToApp = (Button)v.findViewById(R.id.backup_to_app); exportToApp.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { File exportFile = new File(AppContext.getSharingDir(), filename); try { AppContext.getTranslator().exportDokuWiki(targetTranslation, exportFile); } catch (Exception e) { Logger.e(PublishFragment.class.getName(), "Failed to export the target translation " + targetTranslation.getId(), e); } if(exportFile.exists()) { Uri u = FileProvider.getUriForFile(getActivity(), "com.door43.translationstudio.fileprovider", exportFile); Intent i = new Intent(Intent.ACTION_SEND); i.setType("application/zip"); i.putExtra(Intent.EXTRA_STREAM, u); startActivity(Intent.createChooser(i, "Email:")); } else { Snackbar snack = Snackbar.make(getActivity().findViewById(android.R.id.content), R.string.translation_export_failed, Snackbar.LENGTH_LONG); ViewUtil.setSnackBarTextColor(snack, getResources().getColor(R.color.light_primary_text)); snack.show(); } } }); Button exportToSD = (Button)v.findViewById(R.id.export_to_sdcard); exportToSD.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // TODO: 10/27/2015 have the user choose where to save the file File exportFile = new File(AppContext.getPublicDownloadsDirectory(), System.currentTimeMillis() / 1000L + "_" + filename); try { AppContext.getTranslator().exportDokuWiki(targetTranslation, exportFile); } catch (Exception e) { Logger.e(PublishFragment.class.getName(), "Failed to export the target translation " + targetTranslation.getId(), e); } if(exportFile.exists()) { Snackbar snack = Snackbar.make(getActivity().findViewById(android.R.id.content), R.string.success, Snackbar.LENGTH_LONG); ViewUtil.setSnackBarTextColor(snack, getResources().getColor(R.color.light_primary_text)); snack.show(); } else { Snackbar snack = Snackbar.make(getActivity().findViewById(android.R.id.content), R.string.translation_export_failed, Snackbar.LENGTH_LONG); ViewUtil.setSnackBarTextColor(snack, getResources().getColor(R.color.light_primary_text)); snack.show(); } } }); Button exportToDevice = (Button)v.findViewById(R.id.backup_to_device); exportToDevice.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { Snackbar snack = Snackbar.make(getActivity().findViewById(android.R.id.content), "Coming soon", Snackbar.LENGTH_SHORT); ViewUtil.setSnackBarTextColor(snack, getResources().getColor(R.color.light_primary_text)); snack.show(); } }); // Connect to existing tasks PullTargetTranslationTask pullTask = (PullTargetTranslationTask)TaskManager.getTask(PullTargetTranslationTask.TASK_ID); RegisterSSHKeysTask keysTask = (RegisterSSHKeysTask)TaskManager.getTask(RegisterSSHKeysTask.TASK_ID); CreateRepositoryTask repoTask = (CreateRepositoryTask)TaskManager.getTask(CreateRepositoryTask.TASK_ID); PushTargetTranslationTask pushTask = (PushTargetTranslationTask)TaskManager.getTask(PushTargetTranslationTask.TASK_ID); if(pullTask != null) { taskWatcher.watch(pullTask); } else if (keysTask != null) { taskWatcher.watch(keysTask); } else if(repoTask != null) { taskWatcher.watch(repoTask); } else if(pushTask != null) { taskWatcher.watch(pushTask); } // attach to dialogs MergeConflictsDialog mergeConflictsDialog = (MergeConflictsDialog)getFragmentManager().findFragmentByTag(MergeConflictsDialog.TAG); if(mergeConflictsDialog != null) { attachMergeConflictListener(mergeConflictsDialog); } return v; } /** * The publishing tasks are quite complicated so here's an overview in order: * 1. Pull - retreives any outstanding changes from the server. Also checks authentication (goto 2) , and existence of repo (goto 3) * 2. Register Keys - generates ssh keys and registers them with the gogs account. Then tries to pull again. * 3. Create Repo - creates a new repository in gogs. Then tries to pull again. * 4. Push - pushes the target translation to the gogs repo. If authentication fails goto 2 * User intervention is required if there are merge conflicts. * @param task */ @Override public void onFinished(final ManagedTask task) { taskWatcher.stop(); if(task instanceof PullTargetTranslationTask) { PullTargetTranslationTask.Status status = ((PullTargetTranslationTask)task).getStatus(); // TRICKY: we continue to push for unknown status in case the repo was just created (the missing branch is an error) // the pull task will catch any errors if(status == PullTargetTranslationTask.Status.UP_TO_DATE || status == PullTargetTranslationTask.Status.UNKNOWN) { Logger.i(this.getClass().getName(), "Changes on the server were synced with " + targetTranslation.getId()); try { final Handler hand = new Handler(Looper.getMainLooper()); targetTranslation.setPublished(new TargetTranslation.OnPublishedListener() { @Override public void onSuccess() { // begin upload PushTargetTranslationTask task = new PushTargetTranslationTask(targetTranslation, true); taskWatcher.watch(task); TaskManager.addTask(task, PushTargetTranslationTask.TASK_ID); } @Override public void onFailed(Exception e) { hand.post(new Runnable() { @Override public void run() { notifyPublishFailed(targetTranslation); } }); } }); } catch (Exception e) { Logger.e(PublishFragment.class.getName(), "Failed to mark target translation " + targetTranslation.getId() + " as publishable", e); notifyPublishFailed(targetTranslation); return; } } else if(status == PullTargetTranslationTask.Status.AUTH_FAILURE) { Logger.i(this.getClass().getName(), "Authentication failed"); // if we have already tried ask the user if they would like to try again if(AppContext.context().hasSSHKeys()) { showAuthFailure(); return; } RegisterSSHKeysTask keyTask = new RegisterSSHKeysTask(false); taskWatcher.watch(keyTask); TaskManager.addTask(keyTask, RegisterSSHKeysTask.TASK_ID); } else if(status == PullTargetTranslationTask.Status.NO_REMOTE_REPO) { Logger.i(this.getClass().getName(), "The repository " + targetTranslation.getId() + " could not be found"); // create missing repo CreateRepositoryTask repoTask = new CreateRepositoryTask(targetTranslation); taskWatcher.watch(repoTask); TaskManager.addTask(repoTask, CreateRepositoryTask.TASK_ID); } else if(status == PullTargetTranslationTask.Status.MERGE_CONFLICTS) { Logger.i(this.getClass().getName(), "The server contains conflicting changes for " + targetTranslation.getId()); notifyMergeConflicts(((PullTargetTranslationTask)task).getConflicts()); } else { notifyPublishFailed(targetTranslation); } } else if(task instanceof RegisterSSHKeysTask) { if(((RegisterSSHKeysTask)task).isSuccess()) { Logger.i(this.getClass().getName(), "SSH keys were registered with the server"); // try to pull again PullTargetTranslationTask pullTask = new PullTargetTranslationTask(targetTranslation); taskWatcher.watch(pullTask); TaskManager.addTask(pullTask, PullTargetTranslationTask.TASK_ID); } else { notifyPublishFailed(targetTranslation); } } else if(task instanceof CreateRepositoryTask) { if(((CreateRepositoryTask)task).isSuccess()) { Logger.i(this.getClass().getName(), "A new repository " + targetTranslation.getId() + " was created on the server"); PullTargetTranslationTask pullTask = new PullTargetTranslationTask(targetTranslation); taskWatcher.watch(pullTask); TaskManager.addTask(pullTask, PullTargetTranslationTask.TASK_ID); } else { notifyPublishFailed(targetTranslation); } } else if(task instanceof PushTargetTranslationTask) { PushTargetTranslationTask.Status status =((PushTargetTranslationTask)task).getStatus(); final String uploadDetails = ((PushTargetTranslationTask)task).getMessage(); if(status == PushTargetTranslationTask.Status.OK) { Logger.i(this.getClass().getName(), "The target translation " + targetTranslation.getId() + " was pushed to the server"); getListener().finishPublishing(); Handler hand = new Handler(Looper.getMainLooper()); hand.post(new Runnable() { @Override public void run() { mUploadButton.setVisibility(View.GONE); mUploadSuccess.setVisibility(View.VISIBLE); final String publishedUrl = getPublishedUrl(targetTranslation); String format = getActivity().getResources().getString(R.string.project_uploaded_to); final String destinationMessage = String.format(format, publishedUrl); ClickableSpan clickableSpan = new ClickableSpan() { @Override public void onClick(View textView) { Uri uri = Uri.parse(publishedUrl); startActivity(new Intent(Intent.ACTION_VIEW, uri)); } }; final SpannableString clickableDestinationMessage = getClickableText(publishedUrl, destinationMessage, clickableSpan); final CustomAlertDialog dlg = CustomAlertDialog.Create(getActivity()); dlg.setTitle(R.string.success) .setMessage(clickableDestinationMessage) .setAutoDismiss(false) .setPositiveButton(R.string.dismiss, new View.OnClickListener() { @Override public void onClick(View v) { dlg.dismiss(); } }) .setNeutralButton(R.string.label_details, new View.OnClickListener() { @Override public void onClick(View v) { showDetails(uploadDetails); } }) .show("publish-finished"); applyClickableMessageToDialog(clickableDestinationMessage, dlg); } }); } else if(status == PushTargetTranslationTask.Status.AUTH_FAILURE) { Logger.i(this.getClass().getName(), "Authentication failed"); showAuthFailure(); } else { notifyPublishFailed(targetTranslation); } } } /** * display the upload details * @param destinationMessage */ private void showDetails(String destinationMessage) { AlertDialog dialog = new AlertDialog.Builder(getActivity()) .setTitle(R.string.project_uploaded) .setMessage(destinationMessage) .setPositiveButton(R.string.dismiss, null) .show(); TextView textView = (TextView) dialog.findViewById(android.R.id.message); textView.setMaxLines(5); textView.setScroller(new Scroller(getActivity())); textView.setVerticalScrollBarEnabled(true); textView.setMovementMethod(new ScrollingMovementMethod()); } /** * put clickable message in dialog * @param clickableMessage * @param dlg */ private void applyClickableMessageToDialog(final SpannableString clickableMessage, final CustomAlertDialog dlg) { Handler hand = new Handler(Looper.getMainLooper()); hand.post(new Runnable() { // wait until dialog has been created @Override public void run() { try { Dialog dialog = dlg.getDialog(); TextView textView = (TextView) dialog.findViewById(R.id.dialog_content); textView.setText(clickableMessage); textView.setMovementMethod(LinkMovementMethod.getInstance()); } catch (Exception e) { e.printStackTrace(); } } }); } /** * make selected text clickable * @param textToClick * @param entireText * @param clickableSpan * @return */ private SpannableString getClickableText(String textToClick, String entireText, ClickableSpan clickableSpan) { int startIndex = entireText.indexOf(textToClick); int lastIndex; if(startIndex < 0) { // if not found startIndex = 0; lastIndex = textToClick.length(); } else { lastIndex = startIndex + textToClick.length(); } SpannableString clickable = new SpannableString(entireText); clickable.setSpan(clickableSpan,startIndex,lastIndex, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); // set click clickable.setSpan(new UnderlineSpan(),startIndex,lastIndex,0); // underline clickable.setSpan(new StyleSpan(Typeface.BOLD),startIndex,lastIndex,0); // make bold return clickable; } /** * generate the url where the user can see that the published target is stored * @param targetTranslation * @return */ public static String getPublishedUrl(TargetTranslation targetTranslation) { String userName = ""; Profile profile = AppContext.getProfile(); if(profile != null && profile.gogsUser != null) { userName = profile.gogsUser.getUsername(); } String server = ""; try { server = AppContext.context().getUserPreferences().getString(SettingsActivity.KEY_PREF_GIT_SERVER, AppContext.context().getResources().getString(R.string.pref_default_git_server)); } catch (Exception e) { e.printStackTrace(); } String[] parts = server.split("git@"); if(parts.length == 2) { server = parts[1]; } return "https://" + server + "/" + userName + "/" + targetTranslation.getId(); } private void notifyMergeConflicts(Map<String, int[][]> conflicts) { FragmentTransaction ft = getFragmentManager().beginTransaction(); MergeConflictsDialog dialog = new MergeConflictsDialog(); attachMergeConflictListener(dialog); dialog.show(ft, MergeConflictsDialog.TAG); } private void attachMergeConflictListener(MergeConflictsDialog dialog) { dialog.setOnClickListener(new MergeConflictsDialog.OnClickListener() { @Override public void onReview() { // ask parent activity to navigate to a new activity Intent intent = new Intent(getActivity(), TargetTranslationActivity.class); Bundle args = new Bundle(); args.putString(AppContext.EXTRA_TARGET_TRANSLATION_ID, targetTranslation.getId()); // TODO: 4/20/16 it woulid be nice to navigate directly to the first conflict // args.putString(AppContext.EXTRA_CHAPTER_ID, chapterId); // args.putString(AppContext.EXTRA_FRAME_ID, frameId); args.putString(AppContext.EXTRA_VIEW_MODE, TranslationViewMode.REVIEW.toString()); intent.putExtras(args); startActivity(intent); getActivity().finish(); } @Override public void onKeepServer() { try { Git git = targetTranslation.getRepo().getGit(); ResetCommand resetCommand = git.reset(); resetCommand.setMode(ResetCommand.ResetType.HARD) .setRef("backup-master") .call(); targetTranslation.commitSync(); // try to pull again PullTargetTranslationTask pullTask = new PullTargetTranslationTask(targetTranslation, MergeStrategy.THEIRS); taskWatcher.watch(pullTask); TaskManager.addTask(pullTask, PullTargetTranslationTask.TASK_ID); } catch (Exception e) { Logger.e(this.getClass().getName(), "Failed to keep server changes durring publish", e); notifyPublishFailed(targetTranslation); } } @Override public void onKeepLocal() { try { Git git = targetTranslation.getRepo().getGit(); ResetCommand resetCommand = git.reset(); resetCommand.setMode(ResetCommand.ResetType.HARD) .setRef("backup-master") .call(); targetTranslation.commitSync(); // try to pull again PullTargetTranslationTask pullTask = new PullTargetTranslationTask(targetTranslation, MergeStrategy.OURS); taskWatcher.watch(pullTask); TaskManager.addTask(pullTask, PullTargetTranslationTask.TASK_ID); } catch (Exception e) { Logger.e(this.getClass().getName(), "Failed to keep local changes durring publish", e); notifyPublishFailed(targetTranslation); } } @Override public void onCancel() { try { Git git = targetTranslation.getRepo().getGit(); ResetCommand resetCommand = git.reset(); resetCommand.setMode(ResetCommand.ResetType.HARD) .setRef("backup-master") .call(); } catch (Exception e) { Logger.e(this.getClass().getName(), "Failed to restore local changes", e); } // TODO: 4/20/16 notify canceled } }); } public void showAuthFailure() { CustomAlertDialog.Create(getActivity()) .setTitle(R.string.error).setMessage(R.string.auth_failure_retry) .setPositiveButton(R.string.yes, new View.OnClickListener() { @Override public void onClick(View v) { RegisterSSHKeysTask keyTask = new RegisterSSHKeysTask(true); taskWatcher.watch(keyTask); TaskManager.addTask(keyTask, RegisterSSHKeysTask.TASK_ID); } }) .setNegativeButton(R.string.no, new View.OnClickListener() { @Override public void onClick(View v) { notifyPublishFailed(targetTranslation); } }) .show("auth-failed"); } /** * Displays a dialog to the user indicating the publish failed. * Includes an option to submit a bug report * @param targetTranslation */ private void notifyPublishFailed(final TargetTranslation targetTranslation) { final Project project = AppContext.getLibrary().getProject(targetTranslation.getProjectId(), "en"); CustomAlertDialog.Create(getActivity()) .setTitle(R.string.error) .setMessage(R.string.upload_failed) .setPositiveButton(R.string.dismiss, null) .setNeutralButton(R.string.menu_bug, new View.OnClickListener() { @Override public void onClick(View v) { // open bug report dialog FragmentTransaction ft = getFragmentManager().beginTransaction(); Fragment prev = getFragmentManager().findFragmentByTag("bugDialog"); if (prev != null) { ft.remove(prev); } ft.addToBackStack(null); FeedbackDialog dialog = new FeedbackDialog(); Bundle args = new Bundle(); String message = "Failed to publish the translation of " + project.name + " into " + targetTranslation.getTargetLanguageName() + ".\ntargetTranslation: " + targetTranslation.getId() + "\n--------\n\n"; args.putString(FeedbackDialog.ARG_MESSAGE, message); dialog.setArguments(args); dialog.show(ft, "bugDialog"); } }).show("publish-failed"); } @Override public void onSaveInstanceState(Bundle out) { out.putBoolean(STATE_UPLOADED, mUploaded); super.onSaveInstanceState(out); } @Override public void onDestroy() { if(taskWatcher != null) { taskWatcher.stop(); } super.onDestroy(); } }