Java tutorial
/* * Copyright (C) 2016 Jorge Ruesga * * 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.ruesga.rview.fragments; import android.app.Activity; import android.databinding.DataBindingUtil; import android.os.Bundle; import android.os.Handler; import android.support.annotation.Keep; import android.support.annotation.Nullable; import android.support.annotation.StringRes; import android.support.v4.app.Fragment; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; import android.support.v7.widget.ListPopupWindow; import android.text.TextUtils; import android.util.Base64; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import com.ruesga.rview.BaseActivity; import com.ruesga.rview.R; import com.ruesga.rview.adapters.SimpleDropDownAdapter; import com.ruesga.rview.databinding.EditActionsHeaderBinding; import com.ruesga.rview.databinding.EditFileChooserHeaderBinding; import com.ruesga.rview.databinding.EditorFragmentBinding; import com.ruesga.rview.drawer.DrawerNavigationView.OnDrawerNavigationItemSelectedListener; import com.ruesga.rview.fragments.EditFileChooserDialogFragment.MODE; import com.ruesga.rview.gerrit.GerritApi; import com.ruesga.rview.gerrit.model.ChangeEditMessageInput; import com.ruesga.rview.gerrit.model.FileInfo; import com.ruesga.rview.gerrit.model.FileStatus; import com.ruesga.rview.gerrit.model.RenameChangeEditInput; import com.ruesga.rview.misc.AndroidHelper; import com.ruesga.rview.misc.CacheHelper; import com.ruesga.rview.misc.ExceptionHelper; import com.ruesga.rview.misc.FowlerNollVo; import com.ruesga.rview.misc.ModelHelper; import com.ruesga.rview.misc.SerializationManager; import com.ruesga.rview.misc.StringHelper; import com.ruesga.rview.model.Account; import com.ruesga.rview.preferences.Constants; import com.ruesga.rview.preferences.Preferences; import com.ruesga.rview.widget.EditorView; import com.ruesga.rview.widget.EditorView.OnContentChangedListener; import com.ruesga.rview.widget.EditorView.OnMessageListener; import org.apache.commons.io.FileUtils; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.schedulers.Schedulers; import me.tatarka.rxloader2.RxLoader; import me.tatarka.rxloader2.RxLoaderManager; import me.tatarka.rxloader2.RxLoaderManagerCompat; import me.tatarka.rxloader2.RxLoaderObserver; import me.tatarka.rxloader2.safe.SafeObservable; import okhttp3.MediaType; import okhttp3.RequestBody; import okhttp3.ResponseBody; public class EditorFragment extends Fragment implements KeyEventBindable, EditFileChooserDialogFragment.OnEditFileChosen { private static final String TAG = "EditorFragment"; private static final boolean DEBUG = false; private static final int READ_CONTENT_MESSAGE = 0; @Keep public static class Op { @SerializedName("op") public MODE op; @SerializedName("old_path") public String oldPath; @SerializedName("old_info") public FileInfo oldInfo; } private interface OnSavedContentReady { void onContentSaved(); } @Keep public static class Model { public String file; } @Keep @SuppressWarnings("unused") public static class EventHandlers { private final EditorFragment mFragment; public EventHandlers(EditorFragment fragment) { mFragment = fragment; } public void onFileChooserPressed(View v) { mFragment.performFileChooser(v); } public void onShowFileDetailsPressed(View v) { mFragment.performShowFileDetails(); } public void onActionPressed(View v) { mFragment.closeDrawer(); String action = (String) v.getTag(); switch (action) { case "restore": mFragment.performRestore(); break; case "delete_current": mFragment.performDeleteCurrentEdit(); break; case "rename_current": mFragment.performRenameEdit(mFragment.mFile, v); break; case "discard": mFragment.performCancelEdit(); break; case "publish": mFragment.performPublishEdit(); break; case "add": mFragment.performAddEdit(v); break; case "delete": mFragment.performDeleteEdit(v); break; case "rename": mFragment.performRenameEdit(null, v); break; } } } private OnDrawerNavigationItemSelectedListener mOptionsItemListener = new OnDrawerNavigationItemSelectedListener() { @Override public boolean onDrawerNavigationItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.wrap_mode_on: mWrap = true; Preferences.setAccountWrapMode(getContext(), mAccount, mWrap); mBinding.editor.setWrap(mWrap); break; case R.id.wrap_mode_off: mWrap = false; Preferences.setAccountWrapMode(getContext(), mAccount, mWrap); mBinding.editor.setWrap(mWrap); break; case R.id.text_size_smaller: mTextSizeFactor = Constants.DEFAULT_TEXT_SIZE_SMALLER; Preferences.setAccountTextSizeFactor(getContext(), mAccount, mTextSizeFactor); mBinding.editor.setTextSize(12); break; case R.id.text_size_normal: mTextSizeFactor = Constants.DEFAULT_TEXT_SIZE_NORMAL; Preferences.setAccountTextSizeFactor(getContext(), mAccount, mTextSizeFactor); mBinding.editor.setTextSize(14); break; case R.id.text_size_bigger: mTextSizeFactor = Constants.DEFAULT_TEXT_SIZE_BIGGER; Preferences.setAccountTextSizeFactor(getContext(), mAccount, mTextSizeFactor); mBinding.editor.setTextSize(16); break; } // Close the drawer ((BaseActivity) getActivity()).closeOptionsDrawer(); return true; } }; private final RxLoaderObserver<Map<String, FileInfo>> mFilesObserver = new RxLoaderObserver<Map<String, FileInfo>>() { @Override @SuppressWarnings("Convert2streamapi") public void onNext(Map<String, FileInfo> files) { // Update files mFile = null; mFiles.clear(); mFileInfo.clear(); for (String file : files.keySet()) { if (!files.get(file).binary) { mFiles.add(file); mFileInfo.put(file, files.get(file)); } } createFileHashes(); // At least commit message should be present mFile = mFiles.get(0); mCurrentFile = 0; updateModel(); requestFileContent(); showProgress(false); } @Override public void onError(Throwable error) { ((BaseActivity) getActivity()).handleException(TAG, error, null); showProgress(false); } @Override public void onStarted() { showProgress(true); } }; private final RxLoaderObserver<Boolean> mPublishObserver = new RxLoaderObserver<Boolean>() { @Override @SuppressWarnings("Convert2streamapi") public void onNext(Boolean result) { getActivity().setResult(Activity.RESULT_OK); getActivity().finish(); showProgress(false); mPublishLoader.clear(); } @Override public void onError(Throwable error) { ((BaseActivity) getActivity()).handleException(TAG, error, null); showProgress(false); // Try to cancel all published changes requestCancelEdit(); mPublishLoader.clear(); } @Override public void onStarted() { showProgress(true); } }; private final RxLoaderObserver<Boolean> mCancelObserver = new RxLoaderObserver<Boolean>() { @Override @SuppressWarnings("Convert2streamapi") public void onNext(Boolean result) { mCancelLoader.clear(); } }; private final RxLoaderObserver<byte[]> mContentObserver = new RxLoaderObserver<byte[]>() { @Override public void onNext(byte[] content) { byte[] data; if (content == null || content.length == 0) { data = new byte[] {}; } else { data = content; } FileInfo info = mFileInfo.get(mFile); final boolean wasDeleted = info.status.equals(FileStatus.D); if (!wasDeleted) { try { CacheHelper.writeAccountDiffCacheFile(getContext(), getContentCachedFileName(mFile), data); } catch (IOException ex) { Log.w(TAG, "Failed to store content for " + mFile); } } // The response is base64 encoded mBinding.editor.setReadOnly(wasDeleted); mBinding.editor.scrollTo(0, 0); mBinding.editor.loadEncodedContent(mFile, data); if (wasDeleted) { mBinding.setAdvise(getString(R.string.change_edit_deleted_file)); } else { mBinding.setAdvise(null); } showProgress(false); mContentLoader.clear(); mLocked = false; } @Override public void onError(Throwable error) { ((BaseActivity) getActivity()).handleException(TAG, error, null); showProgress(false); mContentLoader.clear(); mLocked = false; } @Override public void onStarted() { showProgress(true); mLocked = true; } }; private final RxLoaderObserver<byte[]> mLocalContentObserver = new RxLoaderObserver<byte[]>() { @Override public void onNext(byte[] content) { byte[] data; if (content == null || content.length == 0) { data = new byte[] {}; } else { data = content; } // The response is not encoded mBinding.editor.scrollTo(0, 0); mBinding.editor.loadContent(mFile, data); showProgress(false); mContentLoader.clear(); mLocked = false; } @Override public void onError(Throwable error) { ((BaseActivity) getActivity()).handleException(TAG, error, null); showProgress(false); mContentLoader.clear(); mLocked = false; } @Override public void onStarted() { showProgress(true); mLocked = true; } }; private OnContentChangedListener mContentChangedListener = new OnContentChangedListener() { @Override public void onContentChanged() { mIsDirty = true; mUiHandler.removeMessages(READ_CONTENT_MESSAGE); mUiHandler.sendEmptyMessageDelayed(READ_CONTENT_MESSAGE, 500L); } }; private OnMessageListener mMessageListener = new OnMessageListener() { @Override public void onErrorMessage(@StringRes int msg) { Log.e(TAG, getString(msg)); ((BaseActivity) getActivity()).showError(msg); } @Override public void onWarnMessage(@StringRes int msg) { Log.w(TAG, getString(msg)); ((BaseActivity) getActivity()).showWarning(msg); } }; private Handler.Callback mOnChangeCallback = msg -> { if (msg.what == READ_CONTENT_MESSAGE) { readFileContent(null); } return false; }; private EditorFragmentBinding mBinding; private EditFileChooserHeaderBinding mFileChooserBinding; private EditActionsHeaderBinding mEditActionsBinding; private final Model mModel = new Model(); private EventHandlers mEventHandlers; private int mLegacyChangeId; private String mChangeId; private String mRevisionId; private ArrayList<String> mFiles = new ArrayList<>(); private Map<String, FileInfo> mFileInfo = new HashMap<>(); private ArrayList<String> mFilesHashes = new ArrayList<>(); private Map<String, Op> mEditOps = new HashMap<>(); private String mFile; private int mCurrentFile; private boolean mIsDirty; private String mContentFile; private boolean mReadOnly; private boolean mLocked; private RxLoader<byte[]> mContentLoader; private RxLoader<Boolean> mCancelLoader; private RxLoader<Boolean> mPublishLoader; private Account mAccount; private boolean mWrap; private float mTextSizeFactor; private Handler mUiHandler; public static EditorFragment newInstance(int legacyChangeId, String changeId, String revisionId) { return newInstance(legacyChangeId, changeId, revisionId, null, null, false); } public static EditorFragment newInstance(int legacyChangeId, String changeId, String revisionId, String file, String content, boolean readOnly) { EditorFragment fragment = new EditorFragment(); Bundle arguments = new Bundle(); arguments.putInt(Constants.EXTRA_LEGACY_CHANGE_ID, legacyChangeId); arguments.putString(Constants.EXTRA_CHANGE_ID, changeId); arguments.putString(Constants.EXTRA_REVISION_ID, revisionId); if (!TextUtils.isEmpty(file)) { arguments.putString(Constants.EXTRA_FILE, file); } if (!TextUtils.isEmpty(content)) { arguments.putString(Constants.EXTRA_CONTENT_FILE, content); } arguments.putBoolean(Constants.EXTRA_READ_ONLY, readOnly); fragment.setArguments(arguments); return fragment; } @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); mEventHandlers = new EventHandlers(this); mUiHandler = new Handler(mOnChangeCallback); Bundle state = (savedInstanceState != null) ? savedInstanceState : getArguments(); mLegacyChangeId = state.getInt(Constants.EXTRA_LEGACY_CHANGE_ID); mChangeId = state.getString(Constants.EXTRA_CHANGE_ID); mRevisionId = state.getString(Constants.EXTRA_REVISION_ID); mFile = state.getString(Constants.EXTRA_FILE); mContentFile = state.getString(Constants.EXTRA_CONTENT_FILE); mReadOnly = state.getBoolean(Constants.EXTRA_READ_ONLY, false); setHasOptionsMenu(true); } @Nullable @Override public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { mBinding = DataBindingUtil.inflate(inflater, R.layout.editor_fragment, container, false); mBinding.editor.setReadOnly(mReadOnly).listenOn(mContentChangedListener).listenOn(mMessageListener); mBinding.setAdvise(null); return mBinding.getRoot(); } @Override public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); startLoadersWithValidContext(savedInstanceState); } @Override public void onDestroyView() { super.onDestroyView(); if (mBinding != null) { mBinding.unbind(); } if (mFileChooserBinding != null) { mFileChooserBinding.unbind(); } if (mEditActionsBinding != null) { mEditActionsBinding.unbind(); } } @Override public void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putInt(Constants.EXTRA_LEGACY_CHANGE_ID, mLegacyChangeId); outState.putString(Constants.EXTRA_CHANGE_ID, mChangeId); outState.putString(Constants.EXTRA_REVISION_ID, mRevisionId); outState.putString(Constants.EXTRA_CONTENT_FILE, mContentFile); outState.putBoolean(Constants.EXTRA_READ_ONLY, mReadOnly); outState.putStringArrayList("files", mFiles); outState.putString("file_infos", SerializationManager.getInstance().toJson(mFileInfo)); outState.putString(Constants.EXTRA_FILE, mFile); outState.putInt("current_file", mCurrentFile); outState.putBoolean("is_dirty", mIsDirty); } private void startLoadersWithValidContext(@Nullable Bundle savedInstanceState) { // Configure the diff_options menu BaseActivity activity = ((BaseActivity) getActivity()); activity.configureOptionsTitle( getString(!mReadOnly ? R.string.menu_edit_options : R.string.menu_view_options)); activity.configureOptionsMenu(R.menu.edit_options_menu, mOptionsItemListener); mAccount = Preferences.getAccount(getContext()); mWrap = Preferences.getAccountWrapMode(getContext(), mAccount); mTextSizeFactor = Preferences.getAccountTextSizeFactor(getContext(), mAccount); if (!mReadOnly) { // Edit mode mFileChooserBinding = DataBindingUtil.inflate(LayoutInflater.from(getContext()), R.layout.edit_file_chooser_header, activity.getOptionsMenu(), false); mFileChooserBinding.setHandlers(mEventHandlers); activity.getOptionsMenu().addHeaderView(mFileChooserBinding.getRoot()); mEditActionsBinding = DataBindingUtil.inflate(LayoutInflater.from(getContext()), R.layout.edit_actions_header, activity.getOptionsMenu(), false); mEditActionsBinding.setHandlers(mEventHandlers); activity.getOptionsMenu().addHeaderView(mEditActionsBinding.getRoot()); // Load the files RxLoaderManager loaderManager = RxLoaderManagerCompat.get(this); mContentLoader = loaderManager.create("content", fetchContent(), mContentObserver); mPublishLoader = loaderManager.create("publish", publishEdit(), mPublishObserver); if (!mReadOnly) { // Load current edit operations readFileOps(); } if (savedInstanceState != null) { mFile = savedInstanceState.getString(Constants.EXTRA_FILE); mFiles = savedInstanceState.getStringArrayList("files"); final String json = savedInstanceState.getString("file_infos"); if (TextUtils.isEmpty(json)) { Type type = new TypeToken<Map<String, FileInfo>>() { }.getType(); mFileInfo = SerializationManager.getInstance().fromJson(json, type); } mCurrentFile = savedInstanceState.getInt("current_file"); mIsDirty = savedInstanceState.getBoolean("is_dirty"); createFileHashes(); updateModel(); requestFileContent(); } else { // Cancel any previous edit and request files mCancelLoader = loaderManager.create("cancel", cancelEdit(), mCancelObserver).start(); loaderManager.create("files", fetchFiles(), mFilesObserver).start(); } } else { // Viewer mode RxLoaderManager loaderManager = RxLoaderManagerCompat.get(this); mContentLoader = loaderManager.create("local_content", fetchLocalContent(), mLocalContentObserver); updateModel(); requestFileContent(); } } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { inflater.inflate(R.menu.edit_options, menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { switch (item.getItemId()) { case R.id.menu_edit_options: openOptionsMenu(); AndroidHelper.hideSoftKeyboard(getContext(), getActivity().getWindow()); break; } return false; } private void openOptionsMenu() { if (!mLocked) { // Update diff_options BaseActivity activity = ((BaseActivity) getActivity()); Menu menu = activity.getOptionsMenu().getMenu(); menu.findItem(R.id.wrap_mode_on).setChecked(mWrap); menu.findItem(R.id.wrap_mode_off).setChecked(!mWrap); menu.findItem(R.id.text_size_smaller) .setChecked(mTextSizeFactor == Constants.DEFAULT_TEXT_SIZE_SMALLER); menu.findItem(R.id.text_size_normal).setChecked(mTextSizeFactor == Constants.DEFAULT_TEXT_SIZE_NORMAL); menu.findItem(R.id.text_size_bigger).setChecked(mTextSizeFactor == Constants.DEFAULT_TEXT_SIZE_BIGGER); if (mEditActionsBinding != null) { mEditActionsBinding.setIsDirty(mBinding.editor.isDirty()); boolean wasDeleted = mFileInfo.get(mFile).status.equals(FileStatus.D); boolean isOp = mEditOps.containsKey(mFile); mEditActionsBinding.setCanPublish(mIsDirty); mEditActionsBinding .setCanDeleteCurrent(!mFile.equals(Constants.COMMIT_MESSAGE) && !wasDeleted && !isOp); mEditActionsBinding .setCanRenameCurrent(!mFile.equals(Constants.COMMIT_MESSAGE) && !wasDeleted && !isOp); } // Open drawer activity.openOptionsDrawer(); } } private void showProgress(boolean show) { BaseActivity activity = (BaseActivity) getActivity(); if (show) { activity.onRefreshStart(this); } else { activity.onRefreshEnd(this, null); } } @SuppressWarnings("ConstantConditions") private Observable<Map<String, FileInfo>> fetchFiles() { final GerritApi api = ModelHelper.getGerritApi(getContext()); return SafeObservable .fromNullCallable(() -> api.getChangeRevisionFiles(String.valueOf(mLegacyChangeId), GerritApi.CURRENT_REVISION, null, null).blockingFirst()) .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); } @SuppressWarnings("ConstantConditions") private Observable<byte[]> fetchContent() { final GerritApi api = ModelHelper.getGerritApi(getContext()); return SafeObservable.fromNullCallable(() -> withCached(SafeObservable.fromNullCallable(() -> { // Deleted files doesn't have content FileInfo info = mFileInfo.get(mFile); if (info.status.equals(FileStatus.D)) { createEmptyEdit(mFile); return "".getBytes(); } String file = info.status.equals(FileStatus.R) ? info.oldPath : mFile; try { ResponseBody body = api.getChangeRevisionFileContent(String.valueOf(mLegacyChangeId), GerritApi.CURRENT_REVISION, file).blockingFirst(); return body.bytes(); } catch (Exception ex) { // If is a new content edit, just initialize the edit if (ExceptionHelper.isResourceNotFoundException(ex) && mFileInfo.get(mFile).status.equals(FileStatus.A)) { createEmptyEdit(mFile); return "".getBytes(); } throw ex; } })).blockingFirst()).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); } @SuppressWarnings("ConstantConditions") private Observable<byte[]> fetchLocalContent() { if (mContentFile == null) { return Observable.just(new byte[] {}); } return SafeObservable .fromNullCallable(() -> mReadOnly ? FileUtils.readFileToByteArray(new File(mContentFile)) : readEditContent(mContentFile)) .subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); } @SuppressWarnings("ConstantConditions") private Observable<Boolean> cancelEdit() { final GerritApi api = ModelHelper.getGerritApi(getContext()); return SafeObservable.fromNullCallable(() -> { api.deleteChangeEdit(String.valueOf(mLegacyChangeId)).blockingFirst(); return true; }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); } private Observable<Boolean> publishEdit() { return SafeObservable.fromNullCallable(() -> { publishEditChanges(); return true; }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()); } @SuppressWarnings("Convert2streamapi") private void performFileChooser(View v) { // Filter out current ops int count = mFileInfo.size(); final List<String> files = new ArrayList<>(count); int[] icons = new int[count]; for (int i = 0; i < count; i++) { final String file = mFiles.get(i); final FileInfo fileInfo = mFileInfo.get(file); files.add(new File(file).getName()); icons[i] = ModelHelper.toFileStatusDrawable(fileInfo.status); } final ListPopupWindow popupWindow = new ListPopupWindow(getContext()); SimpleDropDownAdapter adapter = new SimpleDropDownAdapter(getContext(), files, icons, new File(mFile).getName()); popupWindow.setAnchorView(v); popupWindow.setAdapter(adapter); popupWindow.setContentWidth(adapter.measureContentWidth()); popupWindow.setOnItemClickListener((parent, view, position, id) -> { popupWindow.dismiss(); // Close the drawer BaseActivity activity = (BaseActivity) getActivity(); activity.closeOptionsDrawer(); // Change to the new file if (position != mCurrentFile) { switchToPosition(position); } }); popupWindow.setModal(true); popupWindow.show(); } private void performShowFileDetails() { File file = new File(CacheHelper.getAccountDiffCacheDir(getActivity(), mAccount), getContentCachedFileName(mFile)); FileDetailsDialogFragment fragment = FileDetailsDialogFragment.newInstance(new File(mFile), file.length(), null); fragment.show(getChildFragmentManager(), FileDetailsDialogFragment.TAG); } @SuppressWarnings("ConstantConditions") private void updateModel() { mModel.file = mFile == null ? null : new File(mFile).getName(); if (getActivity() != null && ((AppCompatActivity) getActivity()).getSupportActionBar() != null) { ((AppCompatActivity) getActivity()).getSupportActionBar().setSubtitle(mModel.file); } if (mFileChooserBinding != null) { mFileChooserBinding.setModel(mModel); } } private void requestFileContent() { mContentLoader.clear(); mContentLoader.restart(); } private void requestCancelEdit() { mCancelLoader.clear(); mCancelLoader.restart(); } private void requestPublishEdit() { mPublishLoader.clear(); mPublishLoader.restart(); } private void readFileContent(final OnSavedContentReady cb) { final String file = mFile; mBinding.editor.readContent(new EditorView.OnReadContentReadyListener() { @Override public void onReadContentReady(byte[] content) { if (content.length > 0) { String name = getEditCachedFileName(file); if (DEBUG) { Log.i(TAG, new String(Base64.decode(content, Base64.NO_WRAP))); } try { CacheHelper.writeAccountDiffCacheFile(getActivity(), name, content); mContentFile = new File(CacheHelper.getAccountDiffCacheDir(getActivity(), mAccount), name) .getAbsolutePath(); } catch (IOException ex) { Log.w(TAG, "Failed to store edit for " + file); } } if (cb != null) { cb.onContentSaved(); } } @Override public void onContentUnchanged() { if (cb != null) { cb.onContentSaved(); } } }); } @SuppressWarnings("unchecked") private Observable<byte[]> withCached(Observable<byte[]> call) { final String editName = getEditCachedFileName(mFile); final String contentName = getContentCachedFileName(mFile); try { // Has edit? if (CacheHelper.hasAccountDiffCache(getContext(), editName)) { byte[] o = CacheHelper.readAccountDiffCacheFile(getContext(), editName); if (o != null) { return Observable.just(o); } } // Has content? if (CacheHelper.hasAccountDiffCache(getContext(), contentName)) { byte[] o = CacheHelper.readAccountDiffCacheFile(getContext(), contentName); if (o != null) { return Observable.just(o); } } } catch (IOException ex) { Log.e(TAG, "Failed to load cached data: " + mFile, ex); } catch (JsonParseException ex) { Log.e(TAG, "Failed to parse cached data: " + mFile, ex); } return call; } @Override public boolean onKeyDown(int keycode, KeyEvent e) { switch (keycode) { case KeyEvent.KEYCODE_BACK: if (mBinding.editor.isDirty()) { readFileContent(this::performCancelEdit); return true; } else if (mIsDirty) { performCancelEdit(); return true; } } return false; } private void performRestore() { AlertDialog dialog = new AlertDialog.Builder(getContext()).setTitle(R.string.change_edit_restore_file_title) .setMessage(R.string.change_edit_restore_file_message) .setPositiveButton(R.string.action_restore, (dialogInterface, i) -> restoreFile()) .setNegativeButton(R.string.action_cancel, null).create(); dialog.show(); } private void performCancelEdit() { AlertDialog dialog = new AlertDialog.Builder(getContext()).setTitle(R.string.change_edit_discard_title) .setMessage(R.string.change_edit_discard_message) .setPositiveButton(R.string.action_discard, (dialogInterface, i) -> { getActivity().setResult(Activity.RESULT_CANCELED); getActivity().finish(); }).setNegativeButton(R.string.action_cancel, null).create(); dialog.show(); } private void performPublishEdit() { readFileContent(() -> { AlertDialog dialog = new AlertDialog.Builder(getContext()).setTitle(R.string.change_edit_publish_title) .setMessage(R.string.change_edit_publish_message) .setPositiveButton(R.string.action_publish, (dialogInterface, i) -> requestPublishEdit()) .setNegativeButton(R.string.action_cancel, null).create(); dialog.show(); }); } private void performAddEdit(View v) { EditFileChooserDialogFragment fragment = EditFileChooserDialogFragment.newAddInstance(getActivity(), 0, mLegacyChangeId, mRevisionId, v); fragment.show(getChildFragmentManager(), EditFileChooserDialogFragment.TAG); } private void performDeleteEdit(View v) { EditFileChooserDialogFragment fragment = EditFileChooserDialogFragment.newDeleteInstance(getActivity(), 0, mLegacyChangeId, mRevisionId, v); fragment.show(getChildFragmentManager(), EditFileChooserDialogFragment.TAG); } private void performRenameEdit(String source, View v) { final String[] prevFiles = mFiles.toArray(new String[mFiles.size()]); EditFileChooserDialogFragment fragment = EditFileChooserDialogFragment.newRenameInstance(getActivity(), 0, mLegacyChangeId, mRevisionId, source, prevFiles, v); fragment.show(getChildFragmentManager(), EditFileChooserDialogFragment.TAG); } private void performDeleteCurrentEdit() { } @SuppressWarnings("Convert2streamapi") private void createFileHashes() { mFilesHashes.clear(); for (String file : mFiles) { mFilesHashes.add(FowlerNollVo.fnv1a_64(file.getBytes()).toString()); } } private String getContentCachedFileName(String file) { return FowlerNollVo.fnv1a_64(file.getBytes()).toString() + ".content"; } private String getEditCachedFileName(String file) { return FowlerNollVo.fnv1a_64(file.getBytes()).toString() + ".edit"; } private void closeDrawer() { ((BaseActivity) getActivity()).closeOptionsDrawer(); } @SuppressWarnings("ConstantConditions") private void publishEditChanges() throws IOException { final GerritApi api = ModelHelper.getGerritApi(getContext()); File dir = CacheHelper.getAccountDiffCacheDir(getContext()); File[] edits = dir.listFiles((dir1, name) -> name.endsWith(".edit")); // Send every edit to the server List<String> renames = new ArrayList<>(); for (File edit : edits) { String hash = edit.getName().substring(0, edit.getName().length() - 5); String file = mFiles.get(mFilesHashes.indexOf(hash)); if (file.equals(Constants.COMMIT_MESSAGE)) { ChangeEditMessageInput input = new ChangeEditMessageInput(); input.message = new String(readEditContent(file)); api.setChangeEditMessage(String.valueOf(mLegacyChangeId), input).blockingFirst(); } else { // Extract the mime/type of the file MediaType mediaType = MediaType.parse(StringHelper.getMimeType(new File(file))); boolean wasDeleted = mFileInfo.get(file).status.equals(FileStatus.D); boolean wasRenamed = mFileInfo.get(file).status.equals(FileStatus.R); if (wasDeleted) { api.deleteChangeEditFile(String.valueOf(mLegacyChangeId), file).blockingFirst(); } else if (wasRenamed) { // Rename RenameChangeEditInput input = new RenameChangeEditInput(); input.newPath = file; input.oldPath = mEditOps.get(file).oldPath; api.renameChangeEditFile(String.valueOf(mLegacyChangeId), input).blockingFirst(); // Upload file changes RequestBody body = RequestBody.create(mediaType, readEditContent(file)); api.setChangeEditFile(String.valueOf(mLegacyChangeId), file, body).blockingFirst(); renames.add(file); } else { RequestBody body = RequestBody.create(mediaType, readEditContent(file)); api.setChangeEditFile(String.valueOf(mLegacyChangeId), file, body).blockingFirst(); } } } // Renames without content modification for (Map.Entry<String, Op> entry : mEditOps.entrySet()) { if (entry.getValue().op.equals(MODE.RENAME) && !renames.contains(entry.getKey())) { RenameChangeEditInput input = new RenameChangeEditInput(); input.newPath = entry.getKey(); input.oldPath = mEditOps.get(entry.getKey()).oldPath; api.renameChangeEditFile(String.valueOf(mLegacyChangeId), input).blockingFirst(); } } // And now publish the edit api.publishChangeEdit(String.valueOf(mLegacyChangeId)).blockingFirst(); } private byte[] readEditContent(String file) throws IOException { byte[] o = CacheHelper.readAccountDiffCacheFile(getContext(), getEditCachedFileName(file)); if (o == null || o.length == 0) { return new byte[0]; } return Base64.decode(o, Base64.NO_WRAP); } private void readFileOps() { try { byte[] data = CacheHelper.readAccountDiffCacheFile(getContext(), "edit.ops"); Type type = new TypeToken<Map<String, MODE>>() { }.getType(); mEditOps = SerializationManager.getInstance().fromJson(new String(data), type); } catch (FileNotFoundException ex) { // Ignore } catch (IOException ex) { ((BaseActivity) getActivity()).handleException(TAG, ex, null); } } private void writeFileOps() { try { byte[] data = SerializationManager.getInstance().toJson(mEditOps).getBytes(); CacheHelper.writeAccountDiffCacheFile(getContext(), "edit.ops", data); } catch (IOException ex) { ((BaseActivity) getActivity()).handleException(TAG, ex, null); } } @Override public void onEditFileChosen(int requestCode, MODE mode, String oldValue, String newValue) { // Update operation's structure FileInfo info = new FileInfo(); switch (mode) { case ADD: if (mFiles.contains(newValue)) { // The file exists in the structure. just switch to that file switchToPosition(mFiles.indexOf(newValue)); return; } createOp(mode, null, newValue); info.status = FileStatus.A; break; case DELETE: if (mFiles.contains(newValue) && mFileInfo.get(newValue).status.equals(FileStatus.D)) { // The file exists in the structure. just switch to that file switchToPosition(mFiles.indexOf(newValue)); return; } createOp(mode, null, newValue); info.status = FileStatus.D; break; case RENAME: Op op = createOp(mode, oldValue, newValue); info.status = FileStatus.R; info.oldPath = oldValue; if (mFileInfo.containsKey(oldValue)) { op.oldInfo = mFileInfo.get(oldValue); } break; } writeFileOps(); mIsDirty = true; // Update file structures if (!TextUtils.isEmpty(oldValue)) { mFiles.remove(oldValue); mFileInfo.remove(oldValue); } if (!mFiles.contains(newValue)) { mFiles.add(newValue); mFileInfo.put(newValue, info); } createFileHashes(); // Change to the new file switchToPosition(mFiles.indexOf(newValue)); } private Op createOp(MODE mode, String oldPath, String newPath) { Op op = new Op(); op.op = mode; op.oldPath = oldPath; mEditOps.put(newPath, op); return op; } private void switchToPosition(int position) { if (mBinding.editor.isDirty()) { readFileContent(null); } mFile = mFiles.get(position); mCurrentFile = position; updateModel(); requestFileContent(); } private void createEmptyEdit(String file) { String name = getEditCachedFileName(file); try { CacheHelper.writeAccountDiffCacheFile(getActivity(), name, new byte[] {}); } catch (IOException ex) { Log.w(TAG, "Failed to store edit for " + file); } } private void restoreFile() { CacheHelper.removeAccountDiffCacheFile(getContext(), getEditCachedFileName(mFile)); CacheHelper.removeAccountDiffCacheFile(getContext(), getContentCachedFileName(mFile)); mIsDirty = hasPendingEdits(); boolean isOp = mEditOps.containsKey(mFile); if (isOp) { Op op = mEditOps.get(mFile); mFiles.remove(mFile); mFileInfo.remove(mFile); mFilesHashes.remove(mFile); mEditOps.remove(mFile); if (op.op.equals(MODE.RENAME) && op.oldInfo != null) { mFiles.add(op.oldPath); mFileInfo.put(op.oldPath, op.oldInfo); createFileHashes(); switchToPosition(mFiles.indexOf(op.oldPath)); requestFileContent(); return; } switchToPosition(0); } else { // Just restore the content requestFileContent(); } } private boolean hasPendingEdits() { File dir = CacheHelper.getAccountDiffCacheDir(getContext()); File[] edits = dir.listFiles((dir1, name) -> name.endsWith(".edit")); return edits.length > 0 || mEditOps.size() > 0; } }