diff --git a/app/build.gradle b/app/build.gradle index cf15cb573..578bdbaa9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -115,6 +115,7 @@ dependencies { // ReactiveX implementation 'io.reactivex.rxjava2:rxjava:2.2.21' + implementation 'io.reactivex.rxjava2:rxandroid:2.1.1' // Testing testImplementation 'androidx.test:core:1.5.0' diff --git a/app/src/main/java/it/niedermann/owncloud/notes/NotesApplication.java b/app/src/main/java/it/niedermann/owncloud/notes/NotesApplication.java index b53a3b7bc..82cdf60f5 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/NotesApplication.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/NotesApplication.java @@ -5,6 +5,7 @@ import android.app.Application; import android.content.Context; import android.util.Log; +import android.webkit.WebView; import androidx.appcompat.app.AppCompatDelegate; import androidx.preference.PreferenceManager; @@ -29,6 +30,9 @@ public void onCreate() { lockedPreference = prefs.getBoolean(getString(R.string.pref_key_lock), false); isGridViewEnabled = getDefaultSharedPreferences(this).getBoolean(getString(R.string.pref_key_gridview), false); super.onCreate(); + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true); + } } public static void setAppTheme(DarkModeSetting setting) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java index 4d969382a..162454336 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/BaseNoteFragment.java @@ -71,7 +71,8 @@ public abstract class BaseNoteFragment extends BrandedFragment implements Catego private Note originalNote; private int originalScrollY; protected NotesRepository repo; - private NoteFragmentListener listener; + @Nullable + protected NoteFragmentListener listener; private boolean titleModified = false; protected boolean isNew = true; @@ -143,6 +144,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat @Nullable protected abstract ScrollView getScrollView(); + protected abstract void scrollToY(int scrollY); @Override @@ -240,7 +242,7 @@ public boolean onOptionsItemSelected(MenuItem item) { .show(requireActivity().getSupportFragmentManager(), BaseNoteFragment.class.getSimpleName())); return true; } else if (itemId == R.id.menu_share) { - ShareUtil.openShareDialog(requireContext(), note.getTitle(), note.getContent()); + shareNote(); return false; } else if (itemId == MENU_ID_PIN) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -263,6 +265,10 @@ public boolean onOptionsItemSelected(MenuItem item) { return super.onOptionsItemSelected(item); } + protected void shareNote() { + ShareUtil.openShareDialog(requireContext(), note.getTitle(), note.getContent()); + } + @CallSuper protected void onNoteLoaded(Note note) { this.originalScrollY = note.getScrollY(); @@ -273,10 +279,21 @@ protected void onNoteLoaded(Note note) { if (scrollY > 0) { note.setScrollY(scrollY); } + onScroll(scrollY, oldScrollY); }); } } + /** + * Scroll callback, to be overridden by subclasses. Default implementation is empty + */ + protected void onScroll(int scrollY, int oldScrollY) { + } + + protected boolean shouldShowToolbar() { + return true; + } + public void onCloseNote() { if (!titleModified && originalNote == null && getContent().isEmpty()) { repo.deleteNoteAndSync(localAccount, note.getId()); @@ -367,8 +384,14 @@ public void moveNote(Account account) { } public interface NoteFragmentListener { + enum Mode { + EDIT, PREVIEW, DIRECT_EDIT + } + void close(); void onNoteUpdated(Note note); + + void changeMode(@NonNull Mode mode, boolean reloadNote); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java index 0fbc0bbf8..d5306d8ac 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/EditNoteActivity.java @@ -8,10 +8,12 @@ import android.util.Log; import android.view.Menu; import android.view.MenuItem; +import android.view.View; import android.view.WindowManager; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import androidx.preference.PreferenceManager; @@ -19,6 +21,7 @@ import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; import com.nextcloud.android.sso.helper.SingleAccountHelper; +import com.nextcloud.android.sso.model.SingleSignOnAccount; import java.io.BufferedReader; import java.io.IOException; @@ -34,6 +37,7 @@ import it.niedermann.owncloud.notes.databinding.ActivityEditBinding; import it.niedermann.owncloud.notes.edit.category.CategoryViewModel; import it.niedermann.owncloud.notes.main.MainActivity; +import it.niedermann.owncloud.notes.persistence.NotesRepository; import it.niedermann.owncloud.notes.persistence.entity.Account; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.NavigationCategory; @@ -57,11 +61,14 @@ public class EditNoteActivity extends LockedActivity implements BaseNoteFragment private ActivityEditBinding binding; private BaseNoteFragment fragment; + private NotesRepository repo; @Override protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); + repo = NotesRepository.getInstance(getApplicationContext()); + try { if (SingleAccountHelper.getCurrentSingleSignOnAccount(this) == null) { throw new NoCurrentAccountSelectedException(); @@ -118,9 +125,20 @@ private long getNoteId() { } private long getAccountId() { - return getIntent().getLongExtra(PARAM_ACCOUNT_ID, 0); + final long idParam = getIntent().getLongExtra(PARAM_ACCOUNT_ID, 0); + if (idParam == 0) { + try { + final SingleSignOnAccount ssoAcc = SingleAccountHelper.getCurrentSingleSignOnAccount(this); + return repo.getAccountByName(ssoAcc.name).getId(); + } catch (NextcloudFilesAppAccountNotFoundException | + NoCurrentAccountSelectedException e) { + Log.w(TAG, "getAccountId: no current account", e); + } + } + return idParam; } + /** * Starts the note fragment for an existing note or a new note. * The actual behavior is triggered by the activity's intent. @@ -145,44 +163,109 @@ private void launchNoteFragment() { * @param noteId ID of the existing note. */ private void launchExistingNote(long accountId, long noteId) { - final var prefKeyNoteMode = getString(R.string.pref_key_note_mode); - final var prefKeyLastMode = getString(R.string.pref_key_last_note_mode); - final var prefValueEdit = getString(R.string.pref_value_mode_edit); - final var prefValuePreview = getString(R.string.pref_value_mode_preview); - final var prefValueLast = getString(R.string.pref_value_mode_last); + launchExistingNote(accountId, noteId, null); + } - final var preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - final String mode = preferences.getString(prefKeyNoteMode, prefValueEdit); - final String lastMode = preferences.getString(prefKeyLastMode, prefValueEdit); - boolean editMode = true; - if (prefValuePreview.equals(mode) || (prefValueLast.equals(mode) && prefValuePreview.equals(lastMode))) { - editMode = false; - } - launchExistingNote(accountId, noteId, editMode); + private void launchExistingNote(long accountId, long noteId, @Nullable final String mode) { + launchExistingNote(accountId, noteId, mode, false); } /** * Starts a {@link NoteEditFragment} or {@link NotePreviewFragment} for an existing note. * - * @param noteId ID of the existing note. - * @param edit View-mode of the fragment: - * true for {@link NoteEditFragment}, - * false for {@link NotePreviewFragment}. + * @param noteId ID of the existing note. + * @param mode View-mode of the fragment (pref value or null). If null will be chosen based on + * user preferences. + * @param discardState If true, the state of the fragment will be discarded and a new fragment will be created */ - private void launchExistingNote(long accountId, long noteId, boolean edit) { + private void launchExistingNote(long accountId, long noteId, @Nullable final String mode, final boolean discardState) { // save state of the fragment in order to resume with the same note and originalNote - Fragment.SavedState savedState = null; - if (fragment != null) { - savedState = getSupportFragmentManager().saveFragmentInstanceState(fragment); + runOnUiThread(() -> { + Fragment.SavedState savedState = null; + if (fragment != null && !discardState) { + savedState = getSupportFragmentManager().saveFragmentInstanceState(fragment); + } + fragment = getNoteFragment(accountId, noteId, mode); + if (savedState != null) { + fragment.setInitialSavedState(savedState); + } + replaceFragment(); + }); + } + + private void replaceFragment() { + getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container_view, fragment).commit(); + if (!fragment.shouldShowToolbar()) { + binding.toolbar.setVisibility(View.GONE); + } else { + binding.toolbar.setVisibility(View.VISIBLE); } - fragment = edit - ? NoteEditFragment.newInstance(accountId, noteId) - : NotePreviewFragment.newInstance(accountId, noteId); + } + + + /** + * Returns the preferred mode for the account. If the mode is "remember last" the last mode is returned. + * If the mode is "direct edit" and the account does not support direct edit, the default mode is returned. + */ + private String getPreferenceMode(long accountId) { + + final var prefKeyNoteMode = getString(R.string.pref_key_note_mode); + final var prefKeyLastMode = getString(R.string.pref_key_last_note_mode); + final var defaultMode = getString(R.string.pref_value_mode_edit); + final var prefValueLast = getString(R.string.pref_value_mode_last); + final var prefValueDirectEdit = getString(R.string.pref_value_mode_direct_edit); - if (savedState != null) { - fragment.setInitialSavedState(savedState); + + final var preferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); + final String modePreference = preferences.getString(prefKeyNoteMode, defaultMode); + + String effectiveMode = modePreference; + if (modePreference.equals(prefValueLast)) { + effectiveMode = preferences.getString(prefKeyLastMode, defaultMode); + } + + if (effectiveMode.equals(prefValueDirectEdit)) { + final Account accountById = repo.getAccountById(accountId); + final var directEditAvailable = accountById != null && accountById.isDirectEditingAvailable(); + if (!directEditAvailable) { + effectiveMode = defaultMode; + } + } + + return effectiveMode; + } + + private BaseNoteFragment getNoteFragment(long accountId, long noteId, final @Nullable String modePref) { + + final var effectiveMode = modePref == null ? getPreferenceMode(accountId) : modePref; + + final var prefValueEdit = getString(R.string.pref_value_mode_edit); + final var prefValueDirectEdit = getString(R.string.pref_value_mode_direct_edit); + final var prefValuePreview = getString(R.string.pref_value_mode_preview); + + if (effectiveMode.equals(prefValueEdit)) { + return NoteEditFragment.newInstance(accountId, noteId); + } else if (effectiveMode.equals(prefValueDirectEdit)) { + return NoteDirectEditFragment.newInstance(accountId, noteId); + } else if (effectiveMode.equals(prefValuePreview)) { + return NotePreviewFragment.newInstance(accountId, noteId); + } else { + throw new IllegalStateException("Unknown note modePref: " + modePref); + } + } + + + @NonNull + private BaseNoteFragment getNewNoteFragment(Note newNote) { + final var mode = getPreferenceMode(getAccountId()); + + final var prefValueDirectEdit = getString(R.string.pref_value_mode_direct_edit); + + if (mode.equals(prefValueDirectEdit)) { + return NoteDirectEditFragment.newInstanceWithNewNote(newNote); + } else { + return NoteEditFragment.newInstanceWithNewNote(newNote); } - getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container_view, fragment).commit(); } /** @@ -219,10 +302,11 @@ private void launchNewNote() { content = ""; } final var newNote = new Note(null, Calendar.getInstance(), NoteUtil.generateNonEmptyNoteTitle(content, this), content, categoryTitle, favorite, null); - fragment = NoteEditFragment.newInstanceWithNewNote(newNote); - getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container_view, fragment).commit(); + fragment = getNewNoteFragment(newNote); + replaceFragment(); } + private void launchReadonlyNote() { final var intent = getIntent(); final var content = new StringBuilder(); @@ -238,7 +322,7 @@ private void launchReadonlyNote() { } fragment = NoteReadonlyFragment.newInstance(content.toString()); - getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container_view, fragment).commit(); + replaceFragment(); } @Override @@ -260,10 +344,10 @@ public boolean onOptionsItemSelected(MenuItem item) { close(); return true; } else if (itemId == R.id.menu_preview) { - launchExistingNote(getAccountId(), getNoteId(), false); + changeMode(Mode.PREVIEW, false); return true; } else if (itemId == R.id.menu_edit) { - launchExistingNote(getAccountId(), getNoteId(), true); + changeMode(Mode.EDIT, false); return true; } return super.onOptionsItemSelected(item); @@ -281,8 +365,10 @@ public void close() { final String prefKeyLastMode = getString(R.string.pref_key_last_note_mode); if (fragment instanceof NoteEditFragment) { preferences.edit().putString(prefKeyLastMode, getString(R.string.pref_value_mode_edit)).apply(); - } else { + } else if (fragment instanceof NotePreviewFragment) { preferences.edit().putString(prefKeyLastMode, getString(R.string.pref_value_mode_preview)).apply(); + } else if (fragment instanceof NoteDirectEditFragment) { + preferences.edit().putString(prefKeyLastMode, getString(R.string.pref_value_mode_direct_edit)).apply(); } fragment.onCloseNote(); @@ -308,6 +394,24 @@ public void onNoteUpdated(Note note) { } } + @Override + public void changeMode(@NonNull Mode mode, boolean reloadNote) { + switch (mode) { + case EDIT: + launchExistingNote(getAccountId(), getNoteId(), getString(R.string.pref_value_mode_edit), reloadNote); + break; + case PREVIEW: + launchExistingNote(getAccountId(), getNoteId(), getString(R.string.pref_value_mode_preview), reloadNote); + break; + case DIRECT_EDIT: + launchExistingNote(getAccountId(), getNoteId(), getString(R.string.pref_value_mode_direct_edit), reloadNote); + break; + default: + throw new IllegalStateException("Unknown mode: " + mode); + } + } + + @Override public void onAccountPicked(@NonNull Account account) { fragment.moveNote(account); @@ -318,4 +422,4 @@ public void applyBrand(int color) { final var util = BrandingUtil.of(color, this); util.notes.applyBrandToPrimaryToolbar(binding.appBar, binding.toolbar, colorAccent); } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt new file mode 100644 index 000000000..0c9b9772f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt @@ -0,0 +1,401 @@ +package it.niedermann.owncloud.notes.edit + +import android.annotation.SuppressLint +import android.net.http.SslError +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.webkit.JavascriptInterface +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.ScrollView +import androidx.core.view.isVisible +import com.google.android.material.snackbar.Snackbar +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.android.sso.helper.SingleAccountHelper +import com.nextcloud.android.sso.model.SingleSignOnAccount +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers +import it.niedermann.owncloud.notes.BuildConfig +import it.niedermann.owncloud.notes.R +import it.niedermann.owncloud.notes.branding.Branded +import it.niedermann.owncloud.notes.branding.BrandedSnackbar +import it.niedermann.owncloud.notes.branding.BrandingUtil +import it.niedermann.owncloud.notes.databinding.FragmentNoteDirectEditBinding +import it.niedermann.owncloud.notes.persistence.ApiProvider +import it.niedermann.owncloud.notes.persistence.DirectEditingRepository +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.persistence.sync.NotesAPI +import it.niedermann.owncloud.notes.shared.model.ApiVersion +import it.niedermann.owncloud.notes.shared.model.ISyncCallback +import it.niedermann.owncloud.notes.shared.util.ExtendedFabUtil +import it.niedermann.owncloud.notes.shared.util.rx.DisposableSet +import java.util.concurrent.TimeUnit + +class NoteDirectEditFragment : BaseNoteFragment(), Branded { + private var _binding: FragmentNoteDirectEditBinding? = null + private val binding: FragmentNoteDirectEditBinding + get() = _binding!! + + private val disposables: DisposableSet = DisposableSet() + private var switchToEditPending = false + + val account: SingleSignOnAccount by lazy { + SingleAccountHelper.getCurrentSingleSignOnAccount( + requireContext(), + ) + } + + val notesApi: NotesAPI by lazy { + ApiProvider.getInstance().getNotesAPI(requireContext(), account, ApiVersion.API_VERSION_1_0) + } + + // for hiding / showing the fab + private var scrollStart: Int = 0 + + public override fun getScrollView(): ScrollView? { + return null + } + + override fun scrollToY(y: Int) { + // do nothing + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + Log.d(TAG, "onCreateView() called") + _binding = FragmentNoteDirectEditBinding.inflate(inflater, container, false) + setupFab() + prepareWebView() + return binding.root + } + + @SuppressLint("ClickableViewAccessibility") // touch listener only for UI purposes, no need to handle click + private fun setupFab() { + binding.plainEditingFab.isExtended = false + ExtendedFabUtil.toggleExtendedOnLongClick(binding.plainEditingFab) + // manually detect scroll as we can't get it from the webview (maybe with custom JS?) + binding.noteWebview.setOnTouchListener { _, event -> + when (event.action) { + MotionEvent.ACTION_DOWN -> { + scrollStart = event.y.toInt() + } + MotionEvent.ACTION_UP -> { + val scrollEnd = event.y.toInt() + ExtendedFabUtil.toggleVisibilityOnScroll( + binding.plainEditingFab, + scrollStart, + scrollEnd, + ) + } + } + return@setOnTouchListener false + } + binding.plainEditingFab.setOnClickListener { switchToPlainEdit() } + } + + private fun switchToPlainEdit() { + switchToEditPending = true + binding.noteWebview.evaluateJavascript(JS_CLOSE) { result -> + val resultWithoutQuotes = result.replace("\"", "") + if (resultWithoutQuotes != JS_RESULT_OK) { + Log.w(TAG, "Closing via JS failed: $resultWithoutQuotes") + changeToEditMode() + } + // if result is OK, switch will be handled by JS interface callback + } + } + + override fun onDestroyView() { + super.onDestroyView() + disposables.dispose() + binding.noteWebview.destroy() + _binding = null + } + + override fun onResume() { + super.onResume() + val timeoutDisposable = Single.just(Unit) + .delay(LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .map { + if (!binding.noteWebview.isVisible) { + Log.w(TAG, "Editor not loaded after $LOAD_TIMEOUT_SECONDS seconds") + handleLoadError() + } + }.subscribe() + disposables.add(timeoutDisposable) + } + + override fun onNoteLoaded(note: Note) { + super.onNoteLoaded(note) + Log.d(TAG, "onNoteLoaded() called") + val newNoteParam = arguments?.getSerializable(PARAM_NEWNOTE) as Note? + if (newNoteParam != null || note.remoteId == null) { + createAndLoadNote(note) + } else { + loadNoteInWebView(note) + } + } + + private fun createAndLoadNote(newNote: Note) { + Log.d(TAG, "createAndLoadNote() called") + val noteCreateDisposable = Single + .fromCallable { + notesApi.createNote(newNote).execute().body()!! + } + .map { createdNote -> + repo.updateRemoteId(newNote.id, createdNote.remoteId) + repo.getNoteById(newNote.id) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ createdNote -> + loadNoteInWebView(createdNote) + }, { throwable -> + note = null + handleLoadError() + Log.e(TAG, "createAndLoadNote:", throwable) + }) + disposables.add(noteCreateDisposable) + } + + private fun loadNoteInWebView(note: Note) { + Log.d(TAG, "loadNoteInWebView() called") + val directEditingRepository = + DirectEditingRepository.getInstance(requireContext().applicationContext) + val urlDisposable = directEditingRepository.getDirectEditingUrl(account, note) + .observeOn(AndroidSchedulers.mainThread()).subscribe({ url -> + if (BuildConfig.DEBUG) { + Log.d(TAG, "loadNoteInWebView: url = $url") + } + binding.noteWebview.loadUrl(url) + }, { throwable -> + handleLoadError() + Log.e(TAG, "loadNoteInWebView:", throwable) + }) + disposables.add(urlDisposable) + } + + private fun handleLoadError() { + val snackbar = BrandedSnackbar.make( + binding.plainEditingFab, + getString(R.string.direct_editing_error), + Snackbar.LENGTH_INDEFINITE, + ) + if (note != null) { + snackbar.setAction(R.string.switch_to_plain_editing) { + changeToEditMode() + } + } else { + snackbar.setAction(R.string.action_back) { + close() + } + } + snackbar.show() + } + + override fun shouldShowToolbar(): Boolean = false + + @SuppressLint("SetJavaScriptEnabled") + private fun prepareWebView() { + val webSettings = binding.noteWebview.settings + // enable zoom + webSettings.setSupportZoom(true) + webSettings.builtInZoomControls = true + webSettings.displayZoomControls = false + + // Non-responsive webs are zoomed out when loaded + webSettings.useWideViewPort = true + webSettings.loadWithOverviewMode = true + + // user agent + val userAgent = + getString(R.string.user_agent, getString(R.string.app_name), BuildConfig.VERSION_NAME) + webSettings.userAgentString = userAgent + + // no private data storing + webSettings.savePassword = false + webSettings.saveFormData = false + + // disable local file access + webSettings.allowFileAccess = false + + // enable javascript + webSettings.javaScriptEnabled = true + webSettings.domStorageEnabled = true + + if (BuildConfig.DEBUG) { + // caching disabled in debug mode + binding.noteWebview.settings.cacheMode = WebSettings.LOAD_NO_CACHE + } + + binding.noteWebview.addJavascriptInterface( + DirectEditingMobileInterface(this), + JS_INTERFACE_NAME, + ) + + binding.noteWebview.webViewClient = object : WebViewClient() { + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + super.onReceivedError(view, request, error) + if (request?.isForMainFrame == true) { + handleLoadError() + } + } + + @SuppressLint("WebViewClientOnReceivedSslError") // only for debug mode + override fun onReceivedSslError( + view: WebView?, + handler: SslErrorHandler?, + error: SslError?, + ) { + if (BuildConfig.DEBUG) { + handler?.proceed() + } else { + super.onReceivedSslError(view, handler, error) + } + } + } + } + + /** + * Gets the current content of the EditText field in the UI. + * + * @return String of the current content. + */ + override fun getContent(): String { + // no way to get content from webview + return "" + } + + override fun saveNote(callback: ISyncCallback?) { + val acc = repo.getAccountByName(account.name) + repo.scheduleSync(acc, false) + } + + override fun onCloseNote() { + saveNote(null) + } + + override fun applyBrand(color: Int) { + val util = BrandingUtil.of(color, requireContext()) + util.material.themeExtendedFAB(binding.plainEditingFab) + util.platform.colorCircularProgressBar(binding.progress, ColorRole.PRIMARY) + } + + private class DirectEditingMobileInterface(val noteDirectEditFragment: NoteDirectEditFragment) { + @JavascriptInterface + fun close() { + noteDirectEditFragment.close() + } + + @JavascriptInterface + fun share() { + noteDirectEditFragment.share() + } + + @JavascriptInterface + fun loaded() { + noteDirectEditFragment.onLoaded() + } + } + + private fun close() { + if (switchToEditPending) { + Log.d(TAG, "close: switching to plain edit") + changeToEditMode() + } else { + Log.d(TAG, "close: closing") + listener?.close() + } + } + + private fun changeToEditMode() { + toggleLoadingUI(true) + val updateDisposable = Single.just(note.remoteId) + .map { remoteId -> + val newNote = notesApi.getNote(remoteId).singleOrError().blockingGet().response + val localAccount = repo.getAccountByName(account.name) + repo.updateNoteAndSync(localAccount, note, newNote.content, newNote.title, null) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ + listener?.changeMode(NoteFragmentListener.Mode.EDIT, true) + }, { throwable -> + Log.e(TAG, "changeToEditMode: ", throwable) + listener?.changeMode(NoteFragmentListener.Mode.EDIT, true) + }) + disposables.add(updateDisposable) + } + + private fun share() { + super.shareNote() + } + + private fun onLoaded() { + Log.d(TAG, "onLoaded: note loaded") + toggleLoadingUI(false) + } + + private fun toggleLoadingUI(loading: Boolean) { + activity?.runOnUiThread { + binding.progress.isVisible = loading + binding.noteWebview.isVisible = !loading + binding.plainEditingFab.isVisible = !loading + } + } + + companion object { + private const val TAG = "NoteDirectEditFragment" + private const val LOAD_TIMEOUT_SECONDS = 10L + private const val JS_INTERFACE_NAME = "DirectEditingMobileInterface" + private const val JS_RESULT_OK = "ok" + + // language=js + private val JS_CLOSE = """ + (function () { + var closeIcons = document.getElementsByClassName("icon-close"); + if (closeIcons.length > 0) { + closeIcons[0].click(); + } else { + return "close button not available"; + } + return "$JS_RESULT_OK"; + })(); + """.trimIndent() + + @JvmStatic + fun newInstance(accountId: Long, noteId: Long): BaseNoteFragment { + val fragment = NoteDirectEditFragment() + val args = Bundle() + args.putLong(PARAM_NOTE_ID, noteId) + args.putLong(PARAM_ACCOUNT_ID, accountId) + fragment.arguments = args + return fragment + } + + @JvmStatic + fun newInstanceWithNewNote(newNote: Note?): BaseNoteFragment { + val fragment = NoteDirectEditFragment() + val args = Bundle() + args.putSerializable(PARAM_NEWNOTE, newNote) + fragment.arguments = args + return fragment + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java index 13061417c..9ba17f95a 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java @@ -25,6 +25,7 @@ import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; import com.google.android.material.floatingactionbutton.FloatingActionButton; import it.niedermann.owncloud.notes.R; @@ -103,6 +104,11 @@ protected FloatingActionButton getSearchPrevButton() { return binding.searchPrev; } + @Override + protected @NonNull ExtendedFloatingActionButton getDirectEditingButton() { + return binding.directEditing; + } + @Nullable @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, Bundle savedInstanceState) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java index 0b2991a7d..37048d6ac 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java @@ -23,6 +23,7 @@ import androidx.preference.PreferenceManager; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener; +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; @@ -80,6 +81,11 @@ protected FloatingActionButton getSearchPrevButton() { return binding.searchPrev; } + @Override + protected @NonNull ExtendedFloatingActionButton getDirectEditingButton() { + return binding.directEditing; + } + @Override protected Layout getLayout() { binding.singleNoteContent.onPreDraw(); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java index 4d1ec24fd..10c9510cb 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/SearchableBaseNoteFragment.java @@ -1,6 +1,5 @@ package it.niedermann.owncloud.notes.edit; -import android.graphics.Color; import android.os.Bundle; import android.os.Handler; import android.text.Layout; @@ -16,12 +15,19 @@ import androidx.annotation.Nullable; import androidx.appcompat.widget.SearchView; +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; +import com.nextcloud.android.sso.exceptions.NoCurrentAccountSelectedException; +import com.nextcloud.android.sso.helper.SingleAccountHelper; +import com.nextcloud.android.sso.model.SingleSignOnAccount; import java.util.regex.Pattern; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; +import it.niedermann.owncloud.notes.persistence.entity.Account; +import it.niedermann.owncloud.notes.shared.util.ExtendedFabUtil; public abstract class SearchableBaseNoteFragment extends BaseNoteFragment { @@ -34,6 +40,7 @@ public abstract class SearchableBaseNoteFragment extends BaseNoteFragment { private SearchView searchView; private String searchQuery = null; private static final int delay = 50; // If the search string does not change after $delay ms, then the search task starts. + private boolean directEditAvailable = false; @ColorInt private int color; @@ -54,6 +61,47 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { } } + @Override + protected void onScroll(int scrollY, int oldScrollY) { + super.onScroll(scrollY, oldScrollY); + if (directEditAvailable) { + // only show FAB if search is not active + if (getSearchNextButton() == null || getSearchNextButton().getVisibility() != View.VISIBLE) { + final ExtendedFloatingActionButton directFab = getDirectEditingButton(); + ExtendedFabUtil.toggleVisibilityOnScroll(directFab, scrollY, oldScrollY); + } + } + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + checkDirectEditingAvailable(); + if (directEditAvailable) { + final ExtendedFloatingActionButton directEditingButton = getDirectEditingButton(); + directEditingButton.setExtended(false); + ExtendedFabUtil.toggleExtendedOnLongClick(directEditingButton); + directEditingButton.setOnClickListener(v -> { + if (listener != null) { + listener.changeMode(NoteFragmentListener.Mode.DIRECT_EDIT, false); + } + }); + } else { + getDirectEditingButton().setVisibility(View.GONE); + } + } + + private void checkDirectEditingAvailable() { + try { + final SingleSignOnAccount ssoAccount = SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()); + final Account localAccount = repo.getAccountByName(ssoAccount.name); + directEditAvailable = localAccount != null && localAccount.isDirectEditingAvailable(); + } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { + Log.w(TAG, "checkDirectEditingAvailable: ", e); + directEditAvailable = false; + } + } + @Override public void onPrepareOptionsMenu(@NonNull Menu menu) { super.onPrepareOptionsMenu(menu); @@ -199,7 +247,12 @@ public void onSaveInstanceState(@NonNull Bundle outState) { protected abstract FloatingActionButton getSearchPrevButton(); + @NonNull + protected abstract ExtendedFloatingActionButton getDirectEditingButton(); + + private void showSearchFabs() { + ExtendedFabUtil.setExtendedFabVisibility(getDirectEditingButton(), false); final var next = getSearchNextButton(); final var prev = getSearchPrevButton(); if (prev != null) { @@ -291,5 +344,6 @@ public void applyBrand(int color) { final var util = BrandingUtil.of(color, requireContext()); util.material.themeFAB(getSearchNextButton()); util.material.themeFAB(getSearchPrevButton()); + util.material.themeExtendedFAB(getDirectEditingButton()); } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java index 3ec92ffb5..479529f30 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainActivity.java @@ -10,14 +10,12 @@ import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.FAVORITES; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.RECENT; import static it.niedermann.owncloud.notes.shared.model.ENavigationCategoryType.UNCATEGORIZED; -import static it.niedermann.owncloud.notes.shared.util.NotesColorUtil.contrastRatioIsSufficient; import static it.niedermann.owncloud.notes.shared.util.SSOUtil.askForNewAccount; import android.accounts.NetworkErrorException; import android.animation.AnimatorInflater; import android.app.SearchManager; import android.content.Intent; -import android.graphics.Color; import android.net.Uri; import android.os.Bundle; import android.text.TextUtils; @@ -50,7 +48,6 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.google.android.material.snackbar.Snackbar; import com.nextcloud.android.common.ui.theme.utils.ColorRole; -import com.nextcloud.android.common.ui.util.PlatformThemeUtil; import com.nextcloud.android.sso.AccountImporter; import com.nextcloud.android.sso.exceptions.AccountImportCancelledException; import com.nextcloud.android.sso.exceptions.NextcloudFilesAppAccountNotFoundException; diff --git a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java index 30e365e2d..75d02c52d 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/main/MainViewModel.java @@ -414,6 +414,7 @@ public void synchronizeCapabilities(@NonNull Account localAccount, @NonNull IRes localAccount.setColor(capabilities.getColor()); BrandingUtil.saveBrandColor(getApplication(), localAccount.getColor()); repo.updateApiVersion(localAccount.getId(), capabilities.getApiVersion()); + repo.updateDirectEditingAvailable(localAccount.getId(), capabilities.isDirectEditingAvailable()); callback.onSuccess(null); } catch (Throwable t) { if (t.getClass() == NextcloudHttpRequestFailedException.class || t instanceof NextcloudHttpRequestFailedException) { @@ -662,4 +663,4 @@ private boolean isNetworkUnreachable(@Nullable String input) { final var lower = input.toLowerCase(Locale.ROOT); return lower.contains("failed to connect") && lower.contains("network is unreachable"); } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java index ce6096075..4be0a493d 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/ApiProvider.java @@ -20,6 +20,7 @@ import java.util.concurrent.ConcurrentHashMap; import it.niedermann.owncloud.notes.persistence.sync.CapabilitiesDeserializer; +import it.niedermann.owncloud.notes.persistence.sync.FilesAPI; import it.niedermann.owncloud.notes.persistence.sync.NotesAPI; import it.niedermann.owncloud.notes.persistence.sync.OcsAPI; import it.niedermann.owncloud.notes.shared.model.ApiVersion; @@ -39,11 +40,14 @@ public class ApiProvider { private static final ApiProvider INSTANCE = new ApiProvider(); private static final String API_ENDPOINT_OCS = "/ocs/v2.php/cloud/"; + private static final String API_ENDPOINT_FILES ="/ocs/v2.php/apps/files/api/v1/"; private static final Map API_CACHE = new ConcurrentHashMap<>(); private static final Map API_CACHE_OCS = new ConcurrentHashMap<>(); private static final Map API_CACHE_NOTES = new ConcurrentHashMap<>(); + private static final Map API_CACHE_FILES = new ConcurrentHashMap<>(); + public static ApiProvider getInstance() { return INSTANCE; @@ -77,6 +81,15 @@ public synchronized NotesAPI getNotesAPI(@NonNull Context context, @NonNull Sing return notesAPI; } + public synchronized FilesAPI getFilesAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { + if (API_CACHE_FILES.containsKey(ssoAccount.name)) { + return API_CACHE_FILES.get(ssoAccount.name); + } + final var filesAPI = new NextcloudRetrofitApiBuilder(getNextcloudAPI(context, ssoAccount), API_ENDPOINT_FILES).create(FilesAPI.class); + API_CACHE_FILES.put(ssoAccount.name, filesAPI); + return filesAPI; + } + private synchronized NextcloudAPI getNextcloudAPI(@NonNull Context context, @NonNull SingleSignOnAccount ssoAccount) { if (API_CACHE.containsKey(ssoAccount.name)) { return API_CACHE.get(ssoAccount.name); diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java index ebe6198ed..43ff28812 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesWorker.java @@ -51,6 +51,7 @@ public Result doWork() { repo.updateCapabilitiesETag(account.getId(), capabilities.getETag()); repo.updateBrand(account.getId(), capabilities.getColor()); repo.updateApiVersion(account.getId(), capabilities.getApiVersion()); + repo.updateDirectEditingAvailable(account.getId(), capabilities.isDirectEditingAvailable()); Log.i(TAG, capabilities.toString()); repo.updateDisplayName(account.getId(), CapabilitiesClient.getDisplayName(getApplicationContext(), ssoAccount, ApiProvider.getInstance())); } catch (Throwable e) { diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/DirectEditingRepository.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/DirectEditingRepository.kt new file mode 100644 index 000000000..01cdfb1ac --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/DirectEditingRepository.kt @@ -0,0 +1,69 @@ +package it.niedermann.owncloud.notes.persistence + +import android.app.Application +import android.content.Context +import com.nextcloud.android.sso.model.SingleSignOnAccount +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import it.niedermann.owncloud.notes.persistence.entity.Note +import it.niedermann.owncloud.notes.shared.model.ApiVersion +import it.niedermann.owncloud.notes.shared.model.directediting.DirectEditingRequestBody + +class DirectEditingRepository private constructor(private val applicationContext: Context) { + + private val apiProvider: ApiProvider by lazy { ApiProvider.getInstance() } + private val notesRepository: NotesRepository by lazy { + NotesRepository.getInstance( + applicationContext, + ) + } + + private fun getNotesPath(account: SingleSignOnAccount): Single { + return Single.fromCallable { + val call = notesRepository.getServerSettings(account, ApiVersion.API_VERSION_1_0) + val response = call.execute() + response.body()?.notesPath ?: throw RuntimeException("No notes path available") + }.subscribeOn(Schedulers.io()) + } + + fun getDirectEditingUrl( + account: SingleSignOnAccount, + note: Note, + ): Single { + return getNotesPath(account) + .flatMap { notesPath -> + val filesAPI = apiProvider.getFilesAPI(applicationContext, account) + Single.fromCallable { + val call = + filesAPI.getDirectEditingUrl( + DirectEditingRequestBody( + path = notesPath, + editorId = SUPPORTED_EDITOR_ID, + fileId = note.remoteId!!, + ), + ) + val response = call.execute() + response.body()?.ocs?.data?.url + ?: throw RuntimeException("No url available") + }.subscribeOn(Schedulers.io()) + } + } + + companion object { + private const val SUPPORTED_EDITOR_ID = "text" + + private var instance: DirectEditingRepository? = null + + /** + * @param applicationContext The application context. Do NOT use a view context to prevent leaks. + */ + @JvmStatic + fun getInstance(applicationContext: Context): DirectEditingRepository { + require(applicationContext is Application) + if (instance == null) { + instance = DirectEditingRepository(applicationContext) + } + return instance!! + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java index d062f4c54..a50604073 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesDatabase.java @@ -21,22 +21,7 @@ import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.persistence.entity.NotesListWidgetData; import it.niedermann.owncloud.notes.persistence.entity.SingleNoteWidgetData; -import it.niedermann.owncloud.notes.persistence.migration.Migration_10_11; -import it.niedermann.owncloud.notes.persistence.migration.Migration_11_12; -import it.niedermann.owncloud.notes.persistence.migration.Migration_12_13; -import it.niedermann.owncloud.notes.persistence.migration.Migration_13_14; -import it.niedermann.owncloud.notes.persistence.migration.Migration_14_15; -import it.niedermann.owncloud.notes.persistence.migration.Migration_15_16; -import it.niedermann.owncloud.notes.persistence.migration.Migration_16_17; -import it.niedermann.owncloud.notes.persistence.migration.Migration_17_18; -import it.niedermann.owncloud.notes.persistence.migration.Migration_18_19; -import it.niedermann.owncloud.notes.persistence.migration.Migration_19_20; -import it.niedermann.owncloud.notes.persistence.migration.Migration_20_21; -import it.niedermann.owncloud.notes.persistence.migration.Migration_21_22; -import it.niedermann.owncloud.notes.persistence.migration.Migration_22_23; -import it.niedermann.owncloud.notes.persistence.migration.Migration_23_24; -import it.niedermann.owncloud.notes.persistence.migration.Migration_9_10; - +import it.niedermann.owncloud.notes.persistence.migration.*; @Database( entities = { Account.class, @@ -44,7 +29,7 @@ CategoryOptions.class, SingleNoteWidgetData.class, NotesListWidgetData.class - }, version = 24 + }, version = 25 ) @TypeConverters({Converters.class}) public abstract class NotesDatabase extends RoomDatabase { @@ -80,7 +65,8 @@ private static NotesDatabase create(final Context context) { new Migration_20_21(), new Migration_21_22(context), new Migration_22_23(), - new Migration_23_24(context) + new Migration_23_24(context), + new Migration_24_25() ) .fallbackToDestructiveMigrationOnDowngrade() .fallbackToDestructiveMigration() diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java index 6825fdba3..1ea2f7cbf 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/NotesRepository.java @@ -263,6 +263,9 @@ public void updateCapabilitiesETag(long id, String capabilitiesETag) { public void updateModified(long id, long modified) { db.getAccountDao().updateModified(id, modified); } + public void updateDirectEditingAvailable(final long id, final boolean available) { + db.getAccountDao().updateDirectEditingAvailable(id, available); + } // Notes diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java index bb4a05410..8b3424adf 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/dao/AccountDao.java @@ -21,8 +21,8 @@ public interface AccountDao { @Delete void deleteAccount(Account localAccount); - String getAccounts = "SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName FROM Account"; - String getAccountById = "SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName FROM Account WHERE ID = :accountId"; + String getAccounts = "SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName, directEditingAvailable FROM Account"; + String getAccountById = "SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName, directEditingAvailable FROM Account WHERE ID = :accountId"; @Query(getAccounts) LiveData> getAccounts$(); @@ -36,7 +36,7 @@ public interface AccountDao { @Query(getAccountById) Account getAccountById(long accountId); - @Query("SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName FROM Account WHERE ACCOUNTNAME = :accountName") + @Query("SELECT id, url, userName, accountName, eTag, modified, apiVersion, color, textColor, capabilitiesEtag, COALESCE(displayName, userName) as displayName, directEditingAvailable FROM Account WHERE ACCOUNTNAME = :accountName") Account getAccountByName(String accountName); @Query("SELECT COUNT(*) FROM Account") @@ -59,4 +59,7 @@ public interface AccountDao { @Query("UPDATE Account SET DISPLAYNAME = :displayName WHERE id = :id") void updateDisplayName(long id, @Nullable String displayName); + + @Query("UPDATE Account SET directEditingAvailable = :available WHERE id = :id") + void updateDirectEditingAvailable(long id, boolean available); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java index 24e5222d8..05b957f0f 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/entity/Account.java @@ -10,17 +10,9 @@ import androidx.room.Index; import androidx.room.PrimaryKey; -import org.json.JSONArray; -import org.json.JSONException; - import java.io.Serializable; import java.util.Calendar; -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import java.util.NoSuchElementException; -import it.niedermann.owncloud.notes.shared.model.ApiVersion; import it.niedermann.owncloud.notes.shared.model.Capabilities; @Entity( @@ -60,6 +52,7 @@ public class Account implements Serializable { private String capabilitiesETag; @Nullable private String displayName; + private boolean directEditingAvailable; public Account() { // Default constructor @@ -76,6 +69,7 @@ public Account(@NonNull String url, @NonNull String username, @NonNull String ac public void setCapabilities(@NonNull Capabilities capabilities) { capabilitiesETag = capabilities.getETag(); apiVersion = capabilities.getApiVersion(); + directEditingAvailable = capabilities.isDirectEditingAvailable(); setColor(capabilities.getColor()); } @@ -175,6 +169,14 @@ public void setDisplayName(@Nullable String displayName) { this.displayName = displayName; } + public boolean isDirectEditingAvailable() { + return directEditingAvailable; + } + + public void setDirectEditingAvailable(boolean directEditingAvailable) { + this.directEditingAvailable = directEditingAvailable; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -195,6 +197,7 @@ public boolean equals(Object o) { return false; if (capabilitiesETag != null ? !capabilitiesETag.equals(account.capabilitiesETag) : account.capabilitiesETag != null) return false; + if (directEditingAvailable != account.directEditingAvailable) return false; return true; } @@ -210,6 +213,7 @@ public int hashCode() { result = 31 * result + color; result = 31 * result + textColor; result = 31 * result + (capabilitiesETag != null ? capabilitiesETag.hashCode() : 0); + result = 31 * result + (directEditingAvailable ? 1 : 0); return result; } @@ -227,6 +231,7 @@ public String toString() { ", color=" + color + ", textColor=" + textColor + ", capabilitiesETag='" + capabilitiesETag + '\'' + + ", directEditingAvailable='" + directEditingAvailable + '\'' + '}'; } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_24_25.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_24_25.kt new file mode 100644 index 000000000..e172d1786 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/migration/Migration_24_25.kt @@ -0,0 +1,13 @@ +package it.niedermann.owncloud.notes.persistence.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +@Suppress("ClassName", "Detekt.ClassNaming", "Detekt.MagicNumber") +class Migration_24_25 : Migration(24, 25) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE Account ADD COLUMN directEditingAvailable INTEGER DEFAULT 0 NOT NULL") + // remove capabilities etag to force refresh + db.execSQL("UPDATE Account SET capabilitiesETag = NULL") + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java index d5ae7b494..e3b268ab7 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/CapabilitiesDeserializer.java @@ -31,6 +31,9 @@ public class CapabilitiesDeserializer implements JsonDeserializer private static final String CAPABILITIES_THEMING = "theming"; private static final String CAPABILITIES_THEMING_COLOR = "color"; private static final String CAPABILITIES_THEMING_COLOR_TEXT = "color-text"; + private static final String CAPABILITIES_FILES = "files"; + private static final String CAPABILITIES_FILES_DIRECT_EDITING = "directEditing"; + private static final String CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID = "supportsFileId"; @Override public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { @@ -61,7 +64,21 @@ public Capabilities deserialize(JsonElement json, Type typeOfT, JsonDeserializat } } } + response.setDirectEditingAvailable(hasDirectEditingCapability(capabilities)); } return response; } + + private boolean hasDirectEditingCapability(final JsonObject capabilities) { + if (capabilities.has(CAPABILITIES_FILES)) { + final var files = capabilities.getAsJsonObject(CAPABILITIES_FILES); + if (files.has(CAPABILITIES_FILES_DIRECT_EDITING)) { + final var directEditing = files.getAsJsonObject(CAPABILITIES_FILES_DIRECT_EDITING); + if (directEditing.has(CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID)) { + return directEditing.get(CAPABILITIES_FILES_DIRECT_EDITING_SUPPORTS_FILE_ID).getAsBoolean(); + } + } + } + return false; + } } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/FilesAPI.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/FilesAPI.kt new file mode 100644 index 000000000..a14dcdd84 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/sync/FilesAPI.kt @@ -0,0 +1,18 @@ +package it.niedermann.owncloud.notes.persistence.sync + +import it.niedermann.owncloud.notes.shared.model.OcsResponse +import it.niedermann.owncloud.notes.shared.model.OcsUrl +import it.niedermann.owncloud.notes.shared.model.directediting.DirectEditingInfo +import it.niedermann.owncloud.notes.shared.model.directediting.DirectEditingRequestBody +import retrofit2.Call +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST + +interface FilesAPI { + @GET("directEditing?format=json") + fun getDirectEditingInfo(): Call> + + @POST("directEditing/open?format=json") + fun getDirectEditingUrl(@Body body: DirectEditingRequestBody): Call> +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java index 06bd867d3..e3d739dee 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/Capabilities.java @@ -3,7 +3,6 @@ import android.graphics.Color; import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; import androidx.annotation.Nullable; public class Capabilities { @@ -16,6 +15,8 @@ public class Capabilities { @Nullable private String eTag; + private boolean directEditingAvailable; + public void setApiVersion(String apiVersion) { this.apiVersion = apiVersion; } @@ -49,7 +50,15 @@ public void setTextColor(@ColorInt int textColor) { this.textColor = textColor; } - @NonNull + + public boolean isDirectEditingAvailable() { + return directEditingAvailable; + } + + public void setDirectEditingAvailable(boolean directEditingAvailable) { + this.directEditingAvailable = directEditingAvailable; + } + @Override public String toString() { return "Capabilities{" + @@ -57,6 +66,7 @@ public String toString() { ", color=" + color + ", textColor=" + textColor + ", eTag='" + eTag + '\'' + + ", hasDirectEditing=" + directEditingAvailable + '}'; } -} \ No newline at end of file +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsUrl.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsUrl.kt new file mode 100644 index 000000000..bc9e2a14f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/OcsUrl.kt @@ -0,0 +1,9 @@ +package it.niedermann.owncloud.notes.shared.model + +import com.google.gson.annotations.Expose + +data class OcsUrl( + @Expose + @JvmField + var url: String? = null +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingCreator.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingCreator.kt new file mode 100644 index 000000000..7fb7b5acc --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingCreator.kt @@ -0,0 +1,18 @@ +package it.niedermann.owncloud.notes.shared.model.directediting + +import com.google.gson.annotations.Expose + +data class DirectEditingCreator( + @Expose + val id: String, + @Expose + val editor: String, + @Expose + val name: String, + @Expose + val extension: String, + @Expose + val mimetype: String, + @Expose + val templates: Boolean, +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingEditor.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingEditor.kt new file mode 100644 index 000000000..5e5de2e3d --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingEditor.kt @@ -0,0 +1,19 @@ +package it.niedermann.owncloud.notes.shared.model.directediting + +import com.google.gson.annotations.Expose + +/** + * Editor for direct editing data model + */ +data class DirectEditingEditor( + @Expose + val id: String, + @Expose + val name: String, + @Expose + val mimetypes: ArrayList, + @Expose + val optionalMimetypes: ArrayList, + @Expose + val secure: Boolean, +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingInfo.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingInfo.kt new file mode 100644 index 000000000..47131dd30 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingInfo.kt @@ -0,0 +1,10 @@ +package it.niedermann.owncloud.notes.shared.model.directediting + +import com.google.gson.annotations.Expose + +data class DirectEditingInfo( + @Expose + val editors: Map, + @Expose + val creators: Map, +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingRequestBody.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingRequestBody.kt new file mode 100644 index 000000000..9bfea16bf --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/model/directediting/DirectEditingRequestBody.kt @@ -0,0 +1,12 @@ +package it.niedermann.owncloud.notes.shared.model.directediting + +import com.google.gson.annotations.Expose + +data class DirectEditingRequestBody( + @Expose + val path: String, + @Expose + val editorId: String, + @Expose + val fileId: Long, +) diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ExtendedFabUtil.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ExtendedFabUtil.kt new file mode 100644 index 000000000..a27714fbe --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/ExtendedFabUtil.kt @@ -0,0 +1,65 @@ +package it.niedermann.owncloud.notes.shared.util + +import android.view.View +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import com.google.android.material.R +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton + +object ExtendedFabUtil { + @JvmStatic + fun setExtendedFabVisibility( + extendedFab: ExtendedFloatingActionButton, + visibility: Boolean, + ) { + if (visibility) { + extendedFab.show() + } else { + if (extendedFab.isExtended) { + extendedFab.hide() + } else { + if (extendedFab.animation == null) { + val animation = AnimationUtils.loadAnimation( + extendedFab.context, + R.anim.abc_shrink_fade_out_from_bottom, + ) + animation.setAnimationListener(object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) {} + override fun onAnimationEnd(animation: Animation) { + extendedFab.visibility = View.GONE + } + + override fun onAnimationRepeat(animation: Animation) {} + }) + extendedFab.startAnimation(animation) + } + } + } + } + + @JvmStatic + fun toggleExtendedOnLongClick(extendedFab: ExtendedFloatingActionButton) { + extendedFab.setOnLongClickListener { v: View? -> + if (extendedFab.isExtended) { + extendedFab.shrink() + } else { + extendedFab.extend() + } + true + } + } + + @JvmStatic + fun toggleVisibilityOnScroll( + extendedFab: ExtendedFloatingActionButton, + scrollY: Int, + oldScrollY: Int, + ) { + @Suppress("ConvertTwoComparisonsToRangeCheck") + if (oldScrollY > 0 && scrollY > oldScrollY && extendedFab.isShown) { + setExtendedFabVisibility(extendedFab, false) + } else if (scrollY < oldScrollY && !extendedFab.isShown) { + setExtendedFabVisibility(extendedFab, true) + } + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/rx/DisposableSet.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/rx/DisposableSet.kt new file mode 100644 index 000000000..12982ed64 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/rx/DisposableSet.kt @@ -0,0 +1,16 @@ +package it.niedermann.owncloud.notes.shared.util.rx + +import io.reactivex.disposables.Disposable + +class DisposableSet { + private val disposables = mutableSetOf() + + fun add(disposable: Disposable) { + disposables.add(disposable) + } + + fun dispose() { + disposables.forEach { it.dispose() } + disposables.clear() + } +} diff --git a/app/src/main/res/drawable/ic_notes.xml b/app/src/main/res/drawable/ic_notes.xml new file mode 100644 index 000000000..7ef43fc14 --- /dev/null +++ b/app/src/main/res/drawable/ic_notes.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_rich_editing.xml b/app/src/main/res/drawable/ic_rich_editing.xml new file mode 100644 index 000000000..3f13365f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_rich_editing.xml @@ -0,0 +1,24 @@ + + + + diff --git a/app/src/main/res/layout/fragment_note_direct_edit.xml b/app/src/main/res/layout/fragment_note_direct_edit.xml new file mode 100644 index 000000000..cecad852a --- /dev/null +++ b/app/src/main/res/layout/fragment_note_direct_edit.xml @@ -0,0 +1,41 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_note_edit.xml b/app/src/main/res/layout/fragment_note_edit.xml index 75e6ab846..79cef9468 100644 --- a/app/src/main/res/layout/fragment_note_edit.xml +++ b/app/src/main/res/layout/fragment_note_edit.xml @@ -64,4 +64,19 @@ app:backgroundTint="@color/defaultBrand" app:srcCompat="@drawable/ic_keyboard_arrow_down_white_24dp" tools:visibility="visible" /> - \ No newline at end of file + + + diff --git a/app/src/main/res/layout/fragment_note_preview.xml b/app/src/main/res/layout/fragment_note_preview.xml index bd9aabefa..f9028fb56 100644 --- a/app/src/main/res/layout/fragment_note_preview.xml +++ b/app/src/main/res/layout/fragment_note_preview.xml @@ -61,4 +61,19 @@ app:backgroundTint="@color/defaultBrand" app:srcCompat="@drawable/ic_keyboard_arrow_down_white_24dp" tools:visibility="visible" /> - \ No newline at end of file + + + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index a7557c374..7894ca845 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3,6 +3,7 @@ @string/pref_value_mode_edit @string/pref_value_mode_preview + @string/pref_value_mode_direct_edit @string/pref_value_mode_last @@ -15,4 +16,4 @@ @string/pref_value_theme_dark @string/pref_value_theme_system_default - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4458476d7..87ffff577 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ Last month Display mode for notes + Notes opening behaviour Theme Monospace font Font size @@ -122,6 +123,7 @@ lastNoteMode backgroundSync edit + directEdit preview last small @@ -213,10 +215,17 @@ Manage accounts Formatting - - Open in edit mode - Open in preview mode - Remember my last selection + + Plain edit mode + Plain preview + Rich edit mode + Remember my last selection + + + @string/noteMode_plain_edit + @string/noteMode_plain_preview + @string/noteMode_rich_edit + @string/noteMode_remember_last @@ -357,4 +366,8 @@ Importing notes… Importing note %1$d of %2$d… Account imported. + Error while loading rich editing + Switch to plain editing + Back + Mozilla/5.0 (Android) %1$s-android/%2$s diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 810f7ae6f..e9a13a5d1 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -35,6 +35,16 @@ android:summary="%s" android:title="@string/settings_theme_title" /> + + - -