Loading app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedFragment.javadeleted 100644 → 0 +0 −50 Original line number Diff line number Diff line /* * Nextcloud Notes - Android Client * * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package it.niedermann.owncloud.notes.branding; import android.util.TypedValue; import android.view.Menu; import android.view.MenuInflater; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; public abstract class BrandedFragment extends Fragment implements Branded { @ColorInt protected int colorAccent; @ColorInt protected int colorPrimary; @Override public void onStart() { super.onStart(); final var context = requireContext(); final var typedValue = new TypedValue(); context.getTheme().resolveAttribute(com.google.android.material.R.attr.colorAccent, typedValue, true); colorAccent = typedValue.data; context.getTheme().resolveAttribute(com.google.android.material.R.attr.colorPrimary, typedValue, true); colorPrimary = typedValue.data; @ColorInt final int color = BrandingUtil.readBrandMainColor(context); applyBrand(color); } @Override public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final var utils = BrandingUtil.of(colorAccent, requireContext()); for (int i = 0; i < menu.size(); i++) { if (menu.getItem(i).getIcon() != null) { utils.platform.colorToolbarMenuIcon(requireContext(), menu.getItem(i)); } } } } app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedFragment.kt 0 → 100644 +117 −0 Original line number Diff line number Diff line /* * Nextcloud Notes - Android Client * * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package it.niedermann.owncloud.notes.branding import android.os.Bundle import android.util.TypedValue import android.view.Menu import android.view.MenuInflater import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.core.view.forEach import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.nextcloud.android.common.ui.util.extensions.adjustUIForAPILevel35 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch /** * An abstract base [Fragment] implementation that provides common branding support for UI * components. * * This class reads and applies brand-specific colors (`colorPrimary`, `colorAccent`, etc.) when the * fragment starts, and adjusts UI elements such as toolbar menu icons accordingly. * * Subclasses can extend this to inherit branding behavior while implementing their specific logic. * * @see BrandingUtil for brand color resolution and application. * @see Branded for the interface definition related to branding behavior. */ abstract class BrandedFragment : Fragment(), Branded { @JvmField @ColorInt protected var colorAccent: Int = 0 @JvmField @ColorInt protected var colorPrimary: Int = 0 override fun onCreate(savedInstanceState: Bundle?) { if (activity is AppCompatActivity) { val appCompatActivity = activity as AppCompatActivity appCompatActivity.adjustUIForAPILevel35() } super.onCreate(savedInstanceState) } override fun onStart() { super.onStart() val context = requireContext() val typedValue = TypedValue() context.theme.resolveAttribute( com.google.android.material.R.attr.colorAccent, typedValue, true ) colorAccent = typedValue.data context.theme.resolveAttribute( com.google.android.material.R.attr.colorPrimary, typedValue, true ) colorPrimary = typedValue.data @ColorInt val color = BrandingUtil.readBrandMainColor(context) applyBrand(color) } @Suppress("DEPRECATION") @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) val utils = BrandingUtil.of(colorAccent, requireContext()) menu.forEach { menu -> menu.icon?.let { icon -> utils.platform.colorToolbarMenuIcon(requireContext(), menu) } } } /** * Launches the given [block] of code in the [Dispatchers.IO] context using the [lifecycleScope]. * * This is useful for running long-running or blocking operations (e.g., file or network I/O) * that should not block the main thread. The coroutine will be automatically canceled when * the lifecycle is destroyed. * * @param block The code block to be executed on the IO dispatcher. */ fun lifecycleScopeIOJob(block: () -> Unit) { lifecycleScope.launch(Dispatchers.IO) { block() } } /** * Executes the given [block] on the main (UI) thread. * * This is typically used to perform UI-related tasks such as updating views from a background * thread. Requires [activity] to be non-null; otherwise, the block will not be executed. * * @param block The code block to be executed on the main thread. */ fun onMainThread(block: () -> Unit) { activity?.runOnUiThread { block() } } } app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt +134 −98 Original line number Diff line number Diff line Loading @@ -27,6 +27,7 @@ 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 com.owncloud.android.lib.common.utils.Log_OC import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers Loading @@ -44,12 +45,13 @@ 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 okio.IOException import java.util.concurrent.TimeUnit class NoteDirectEditFragment : BaseNoteFragment(), Branded { private var _binding: FragmentNoteDirectEditBinding? = null private val binding: FragmentNoteDirectEditBinding get() = _binding!! private val binding: FragmentNoteDirectEditBinding? get() = _binding private val disposables: DisposableSet = DisposableSet() private var switchToEditPending = false Loading Loading @@ -79,20 +81,22 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { ): View? { Log.d(TAG, "onCreateView() called") _binding = FragmentNoteDirectEditBinding.inflate(inflater, container, false) setupFab() prepareWebView() return binding.root 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) binding?.run { plainEditingFab.isExtended = false ExtendedFabUtil.toggleExtendedOnLongClick(plainEditingFab) // manually detect scroll as we can't get it from the webview (maybe with custom JS?) binding.noteWebview.setOnTouchListener { _, event -> noteWebview.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { scrollStart = event.y.toInt() Loading @@ -100,7 +104,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { MotionEvent.ACTION_UP -> { val scrollEnd = event.y.toInt() ExtendedFabUtil.toggleVisibilityOnScroll( binding.plainEditingFab, plainEditingFab, scrollStart, scrollEnd, ) Loading @@ -108,12 +112,13 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { } return@setOnTouchListener false } binding.plainEditingFab.setOnClickListener { switchToPlainEdit() } plainEditingFab.setOnClickListener { switchToPlainEdit() } } } private fun switchToPlainEdit() { switchToEditPending = true binding.noteWebview.evaluateJavascript(JS_CLOSE) { result -> binding?.noteWebview?.evaluateJavascript(JS_CLOSE) { result -> val resultWithoutQuotes = result.replace("\"", "") if (resultWithoutQuotes != JS_RESULT_OK) { Log.w(TAG, "Closing via JS failed: $resultWithoutQuotes") Loading @@ -126,7 +131,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { override fun onDestroyView() { super.onDestroyView() disposables.dispose() binding.noteWebview.destroy() binding?.noteWebview?.destroy() _binding = null } Loading @@ -135,7 +140,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { val timeoutDisposable = Single.just(Unit) .delay(LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) .map { if (!binding.noteWebview.isVisible) { if (binding?.noteWebview?.isVisible == false) { Log.w(TAG, "Editor not loaded after $LOAD_TIMEOUT_SECONDS seconds") handleLoadError() } Loading @@ -158,11 +163,19 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { Log.d(TAG, "createAndLoadNote() called") val noteCreateDisposable = Single .fromCallable { notesApi.createNote(newNote).execute().body()!! try { val response = notesApi.createNote(newNote).execute() response.body() } catch (e: IOException) { Log_OC.w(TAG, "Cant able to create a note: $e") null } } .flatMap { createdNote -> createdNote?.let { repo.updateRemoteId(newNote.id, it.remoteId) Single.fromCallable { repo.getNoteById(newNote.id) } } .map { createdNote -> repo.updateRemoteId(newNote.id, createdNote.remoteId) repo.getNoteById(newNote.id) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) Loading @@ -178,27 +191,33 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { private fun loadNoteInWebView(note: Note) { Log.d(TAG, "loadNoteInWebView() called") val directEditingRepository = DirectEditingRepository.getInstance(requireContext().applicationContext) val urlDisposable = directEditingRepository.getDirectEditingUrl(account, note) context?.let { context -> val repository = DirectEditingRepository.getInstance(context.applicationContext) val urlDisposable = repository.getDirectEditingUrl(account, note) .observeOn(AndroidSchedulers.mainThread()).subscribe({ url -> url?.let { if (BuildConfig.DEBUG) { Log.d(TAG, "loadNoteInWebView: url = $url") } binding.noteWebview.loadUrl(url) binding?.noteWebview?.loadUrl(url) } }, { throwable -> handleLoadError() Log.e(TAG, "loadNoteInWebView:", throwable) }) disposables.add(urlDisposable) } } private fun handleLoadError() { binding?.run { val snackbar = BrandedSnackbar.make( binding.plainEditingFab, plainEditingFab, getString(R.string.direct_editing_error), Snackbar.LENGTH_INDEFINITE, ) if (note != null) { snackbar.setAction(R.string.switch_to_plain_editing) { changeToEditMode() Loading @@ -208,50 +227,53 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { close() } } snackbar.show() } } override fun shouldShowToolbar(): Boolean = false @SuppressLint("SetJavaScriptEnabled") private fun prepareWebView() { val webSettings = binding.noteWebview.settings binding?.noteWebview?.settings?.run { // enable zoom webSettings.setSupportZoom(true) webSettings.builtInZoomControls = true webSettings.displayZoomControls = false setSupportZoom(true) builtInZoomControls = true displayZoomControls = false // Non-responsive webs are zoomed out when loaded webSettings.useWideViewPort = true webSettings.loadWithOverviewMode = true useWideViewPort = true loadWithOverviewMode = true // user agent val userAgent = getString(R.string.user_agent, getString(R.string.app_name), BuildConfig.VERSION_NAME) webSettings.userAgentString = userAgent userAgentString = userAgent // no private data storing webSettings.savePassword = false webSettings.saveFormData = false savePassword = false saveFormData = false // disable local file access webSettings.allowFileAccess = false allowFileAccess = false // enable javascript webSettings.javaScriptEnabled = true webSettings.domStorageEnabled = true javaScriptEnabled = true domStorageEnabled = true } if (BuildConfig.DEBUG) { // caching disabled in debug mode binding.noteWebview.settings.cacheMode = WebSettings.LOAD_NO_CACHE binding?.noteWebview?.settings?.cacheMode = WebSettings.LOAD_NO_CACHE } binding.noteWebview.addJavascriptInterface( binding?.noteWebview?.addJavascriptInterface( DirectEditingMobileInterface(this), JS_INTERFACE_NAME, ) binding.noteWebview.webViewClient = object : WebViewClient() { binding?.noteWebview?.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, Loading Loading @@ -299,8 +321,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { override fun applyBrand(color: Int) { val util = BrandingUtil.of(color, requireContext()) util.material.themeExtendedFAB(binding.plainEditingFab) util.platform.colorCircularProgressBar(binding.progress, ColorRole.PRIMARY) binding?.run { util.material.themeExtendedFAB(plainEditingFab) util.platform.colorCircularProgressBar(progress, ColorRole.PRIMARY) } } private class DirectEditingMobileInterface(val noteDirectEditFragment: NoteDirectEditFragment) { Loading Loading @@ -331,6 +356,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { } private fun changeToEditMode() { if (note == null || note.remoteId == null) { Log.d(TAG, "note is null, cant edit") return } toggleLoadingUI(true) val updateDisposable = Single.just(note.remoteId) .map { remoteId -> Loading Loading @@ -360,9 +390,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { private fun toggleLoadingUI(loading: Boolean) { activity?.runOnUiThread { binding.progress.isVisible = loading binding.noteWebview.isVisible = !loading binding.plainEditingFab.isVisible = !loading binding?.run { progress.isVisible = loading noteWebview.isVisible = !loading plainEditingFab.isVisible = !loading } } } Loading @@ -387,21 +419,25 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { @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 val bundle = Bundle().apply { putLong(PARAM_NOTE_ID, noteId) putLong(PARAM_ACCOUNT_ID, accountId) } return NoteDirectEditFragment().apply { arguments = bundle } } @JvmStatic fun newInstanceWithNewNote(newNote: Note?): BaseNoteFragment { val fragment = NoteDirectEditFragment() val args = Bundle() args.putSerializable(PARAM_NEWNOTE, newNote) fragment.arguments = args return fragment val bundle = Bundle().apply { putSerializable(PARAM_NEWNOTE, newNote) } return NoteDirectEditFragment().apply { arguments = bundle } } } } app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java +25 −10 Original line number Diff line number Diff line Loading @@ -40,6 +40,8 @@ import it.niedermann.owncloud.notes.databinding.FragmentNoteEditBinding; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; import it.niedermann.owncloud.notes.shared.util.DisplayUtils; import kotlin.Unit; import kotlin.jvm.functions.Function0; public class NoteEditFragment extends SearchableBaseNoteFragment { Loading Loading @@ -180,15 +182,24 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { @Override protected void onNoteLoaded(Note note) { super.onNoteLoaded(note); if (binding == null) { return; } if (TextUtils.isEmpty(note.getContent())) { openSoftKeyboard(); } binding.editContent.setMarkdownString(note.getContent()); binding.editContent.setEnabled(true); lifecycleScopeIOJob(() -> { // load potential big note on IO Dispatchers final String content = note.getContent(); final var sp = PreferenceManager.getDefaultSharedPreferences(requireContext().getApplicationContext()); onMainThread(() -> { binding.editContent.setMarkdownString(content); binding.editContent.setEnabled(true); binding.editContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(requireContext(), sp)); if (sp.getBoolean(getString(R.string.pref_key_font), false)) { binding.editContent.setTypeface(Typeface.MONOSPACE); } Loading @@ -196,6 +207,10 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { if (lastSelection > 0 && binding.editContent.length() >= lastSelection) { binding.editContent.setSelection(lastSelection); } return Unit.INSTANCE; }); return Unit.INSTANCE; }); } private void openSoftKeyboard() { Loading app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java +38 −13 Original line number Diff line number Diff line Loading @@ -34,12 +34,16 @@ 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.owncloud.android.lib.common.utils.Log_OC; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.FragmentNotePreviewBinding; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; import it.niedermann.owncloud.notes.shared.util.SSOUtil; import kotlin.Unit; import kotlin.jvm.functions.Function0; public class NotePreviewFragment extends SearchableBaseNoteFragment implements OnRefreshListener { Loading Loading @@ -135,12 +139,27 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O super.onNoteLoaded(note); noteLoaded = true; registerInternalNoteLinkHandler(); changedText = note.getContent(); binding.singleNoteContent.setMarkdownString(note.getContent(), setScrollY); binding.singleNoteContent.getMarkdownString().observe(requireActivity(), (newContent) -> { lifecycleScopeIOJob(() -> { final String content = note.getContent(); changedText = content; onMainThread(() -> { binding.singleNoteContent.setMarkdownString(content, setScrollY); final var activity = getActivity(); if (activity == null) { return Unit.INSTANCE; } binding.singleNoteContent.getMarkdownString().observe(activity, (newContent) -> { changedText = newContent.toString(); saveNote(null); }); return Unit.INSTANCE; }); return Unit.INSTANCE; }); } protected void registerInternalNoteLinkHandler() { Loading Loading @@ -176,21 +195,27 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O public void onRefresh() { if (noteLoaded && repo.isSyncPossible() && SSOUtil.isConfigured(getContext())) { binding.swiperefreshlayout.setRefreshing(true); executor.submit(() -> { lifecycleScopeIOJob(() -> { try { final var account = repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()).name); repo.addCallbackPull(account, () -> executor.submit(() -> { repo.addCallbackPull(account, () -> { note = repo.getNoteById(note.getId()); changedText = note.getContent(); requireActivity().runOnUiThread(() -> { binding.singleNoteContent.setMarkdownString(note.getContent()); final String content = note.getContent(); changedText = content; onMainThread(() -> { binding.singleNoteContent.setMarkdownString(content); binding.swiperefreshlayout.setRefreshing(false); return Unit.INSTANCE; }); })); }); repo.scheduleSync(account, false); } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { e.printStackTrace(); } catch (Exception e) { Log_OC.e(TAG, "onRefresh exception: " + e); } return Unit.INSTANCE; }); } else { binding.swiperefreshlayout.setRefreshing(false); Loading Loading
app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedFragment.javadeleted 100644 → 0 +0 −50 Original line number Diff line number Diff line /* * Nextcloud Notes - Android Client * * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package it.niedermann.owncloud.notes.branding; import android.util.TypedValue; import android.view.Menu; import android.view.MenuInflater; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; public abstract class BrandedFragment extends Fragment implements Branded { @ColorInt protected int colorAccent; @ColorInt protected int colorPrimary; @Override public void onStart() { super.onStart(); final var context = requireContext(); final var typedValue = new TypedValue(); context.getTheme().resolveAttribute(com.google.android.material.R.attr.colorAccent, typedValue, true); colorAccent = typedValue.data; context.getTheme().resolveAttribute(com.google.android.material.R.attr.colorPrimary, typedValue, true); colorPrimary = typedValue.data; @ColorInt final int color = BrandingUtil.readBrandMainColor(context); applyBrand(color); } @Override public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final var utils = BrandingUtil.of(colorAccent, requireContext()); for (int i = 0; i < menu.size(); i++) { if (menu.getItem(i).getIcon() != null) { utils.platform.colorToolbarMenuIcon(requireContext(), menu.getItem(i)); } } } }
app/src/main/java/it/niedermann/owncloud/notes/branding/BrandedFragment.kt 0 → 100644 +117 −0 Original line number Diff line number Diff line /* * Nextcloud Notes - Android Client * * SPDX-FileCopyrightText: 2020-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package it.niedermann.owncloud.notes.branding import android.os.Bundle import android.util.TypedValue import android.view.Menu import android.view.MenuInflater import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.core.view.forEach import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.nextcloud.android.common.ui.util.extensions.adjustUIForAPILevel35 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch /** * An abstract base [Fragment] implementation that provides common branding support for UI * components. * * This class reads and applies brand-specific colors (`colorPrimary`, `colorAccent`, etc.) when the * fragment starts, and adjusts UI elements such as toolbar menu icons accordingly. * * Subclasses can extend this to inherit branding behavior while implementing their specific logic. * * @see BrandingUtil for brand color resolution and application. * @see Branded for the interface definition related to branding behavior. */ abstract class BrandedFragment : Fragment(), Branded { @JvmField @ColorInt protected var colorAccent: Int = 0 @JvmField @ColorInt protected var colorPrimary: Int = 0 override fun onCreate(savedInstanceState: Bundle?) { if (activity is AppCompatActivity) { val appCompatActivity = activity as AppCompatActivity appCompatActivity.adjustUIForAPILevel35() } super.onCreate(savedInstanceState) } override fun onStart() { super.onStart() val context = requireContext() val typedValue = TypedValue() context.theme.resolveAttribute( com.google.android.material.R.attr.colorAccent, typedValue, true ) colorAccent = typedValue.data context.theme.resolveAttribute( com.google.android.material.R.attr.colorPrimary, typedValue, true ) colorPrimary = typedValue.data @ColorInt val color = BrandingUtil.readBrandMainColor(context) applyBrand(color) } @Suppress("DEPRECATION") @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) val utils = BrandingUtil.of(colorAccent, requireContext()) menu.forEach { menu -> menu.icon?.let { icon -> utils.platform.colorToolbarMenuIcon(requireContext(), menu) } } } /** * Launches the given [block] of code in the [Dispatchers.IO] context using the [lifecycleScope]. * * This is useful for running long-running or blocking operations (e.g., file or network I/O) * that should not block the main thread. The coroutine will be automatically canceled when * the lifecycle is destroyed. * * @param block The code block to be executed on the IO dispatcher. */ fun lifecycleScopeIOJob(block: () -> Unit) { lifecycleScope.launch(Dispatchers.IO) { block() } } /** * Executes the given [block] on the main (UI) thread. * * This is typically used to perform UI-related tasks such as updating views from a background * thread. Requires [activity] to be non-null; otherwise, the block will not be executed. * * @param block The code block to be executed on the main thread. */ fun onMainThread(block: () -> Unit) { activity?.runOnUiThread { block() } } }
app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt +134 −98 Original line number Diff line number Diff line Loading @@ -27,6 +27,7 @@ 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 com.owncloud.android.lib.common.utils.Log_OC import io.reactivex.Single import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers Loading @@ -44,12 +45,13 @@ 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 okio.IOException import java.util.concurrent.TimeUnit class NoteDirectEditFragment : BaseNoteFragment(), Branded { private var _binding: FragmentNoteDirectEditBinding? = null private val binding: FragmentNoteDirectEditBinding get() = _binding!! private val binding: FragmentNoteDirectEditBinding? get() = _binding private val disposables: DisposableSet = DisposableSet() private var switchToEditPending = false Loading Loading @@ -79,20 +81,22 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View { ): View? { Log.d(TAG, "onCreateView() called") _binding = FragmentNoteDirectEditBinding.inflate(inflater, container, false) setupFab() prepareWebView() return binding.root 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) binding?.run { plainEditingFab.isExtended = false ExtendedFabUtil.toggleExtendedOnLongClick(plainEditingFab) // manually detect scroll as we can't get it from the webview (maybe with custom JS?) binding.noteWebview.setOnTouchListener { _, event -> noteWebview.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { scrollStart = event.y.toInt() Loading @@ -100,7 +104,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { MotionEvent.ACTION_UP -> { val scrollEnd = event.y.toInt() ExtendedFabUtil.toggleVisibilityOnScroll( binding.plainEditingFab, plainEditingFab, scrollStart, scrollEnd, ) Loading @@ -108,12 +112,13 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { } return@setOnTouchListener false } binding.plainEditingFab.setOnClickListener { switchToPlainEdit() } plainEditingFab.setOnClickListener { switchToPlainEdit() } } } private fun switchToPlainEdit() { switchToEditPending = true binding.noteWebview.evaluateJavascript(JS_CLOSE) { result -> binding?.noteWebview?.evaluateJavascript(JS_CLOSE) { result -> val resultWithoutQuotes = result.replace("\"", "") if (resultWithoutQuotes != JS_RESULT_OK) { Log.w(TAG, "Closing via JS failed: $resultWithoutQuotes") Loading @@ -126,7 +131,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { override fun onDestroyView() { super.onDestroyView() disposables.dispose() binding.noteWebview.destroy() binding?.noteWebview?.destroy() _binding = null } Loading @@ -135,7 +140,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { val timeoutDisposable = Single.just(Unit) .delay(LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) .map { if (!binding.noteWebview.isVisible) { if (binding?.noteWebview?.isVisible == false) { Log.w(TAG, "Editor not loaded after $LOAD_TIMEOUT_SECONDS seconds") handleLoadError() } Loading @@ -158,11 +163,19 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { Log.d(TAG, "createAndLoadNote() called") val noteCreateDisposable = Single .fromCallable { notesApi.createNote(newNote).execute().body()!! try { val response = notesApi.createNote(newNote).execute() response.body() } catch (e: IOException) { Log_OC.w(TAG, "Cant able to create a note: $e") null } } .flatMap { createdNote -> createdNote?.let { repo.updateRemoteId(newNote.id, it.remoteId) Single.fromCallable { repo.getNoteById(newNote.id) } } .map { createdNote -> repo.updateRemoteId(newNote.id, createdNote.remoteId) repo.getNoteById(newNote.id) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) Loading @@ -178,27 +191,33 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { private fun loadNoteInWebView(note: Note) { Log.d(TAG, "loadNoteInWebView() called") val directEditingRepository = DirectEditingRepository.getInstance(requireContext().applicationContext) val urlDisposable = directEditingRepository.getDirectEditingUrl(account, note) context?.let { context -> val repository = DirectEditingRepository.getInstance(context.applicationContext) val urlDisposable = repository.getDirectEditingUrl(account, note) .observeOn(AndroidSchedulers.mainThread()).subscribe({ url -> url?.let { if (BuildConfig.DEBUG) { Log.d(TAG, "loadNoteInWebView: url = $url") } binding.noteWebview.loadUrl(url) binding?.noteWebview?.loadUrl(url) } }, { throwable -> handleLoadError() Log.e(TAG, "loadNoteInWebView:", throwable) }) disposables.add(urlDisposable) } } private fun handleLoadError() { binding?.run { val snackbar = BrandedSnackbar.make( binding.plainEditingFab, plainEditingFab, getString(R.string.direct_editing_error), Snackbar.LENGTH_INDEFINITE, ) if (note != null) { snackbar.setAction(R.string.switch_to_plain_editing) { changeToEditMode() Loading @@ -208,50 +227,53 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { close() } } snackbar.show() } } override fun shouldShowToolbar(): Boolean = false @SuppressLint("SetJavaScriptEnabled") private fun prepareWebView() { val webSettings = binding.noteWebview.settings binding?.noteWebview?.settings?.run { // enable zoom webSettings.setSupportZoom(true) webSettings.builtInZoomControls = true webSettings.displayZoomControls = false setSupportZoom(true) builtInZoomControls = true displayZoomControls = false // Non-responsive webs are zoomed out when loaded webSettings.useWideViewPort = true webSettings.loadWithOverviewMode = true useWideViewPort = true loadWithOverviewMode = true // user agent val userAgent = getString(R.string.user_agent, getString(R.string.app_name), BuildConfig.VERSION_NAME) webSettings.userAgentString = userAgent userAgentString = userAgent // no private data storing webSettings.savePassword = false webSettings.saveFormData = false savePassword = false saveFormData = false // disable local file access webSettings.allowFileAccess = false allowFileAccess = false // enable javascript webSettings.javaScriptEnabled = true webSettings.domStorageEnabled = true javaScriptEnabled = true domStorageEnabled = true } if (BuildConfig.DEBUG) { // caching disabled in debug mode binding.noteWebview.settings.cacheMode = WebSettings.LOAD_NO_CACHE binding?.noteWebview?.settings?.cacheMode = WebSettings.LOAD_NO_CACHE } binding.noteWebview.addJavascriptInterface( binding?.noteWebview?.addJavascriptInterface( DirectEditingMobileInterface(this), JS_INTERFACE_NAME, ) binding.noteWebview.webViewClient = object : WebViewClient() { binding?.noteWebview?.webViewClient = object : WebViewClient() { override fun onReceivedError( view: WebView?, request: WebResourceRequest?, Loading Loading @@ -299,8 +321,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { override fun applyBrand(color: Int) { val util = BrandingUtil.of(color, requireContext()) util.material.themeExtendedFAB(binding.plainEditingFab) util.platform.colorCircularProgressBar(binding.progress, ColorRole.PRIMARY) binding?.run { util.material.themeExtendedFAB(plainEditingFab) util.platform.colorCircularProgressBar(progress, ColorRole.PRIMARY) } } private class DirectEditingMobileInterface(val noteDirectEditFragment: NoteDirectEditFragment) { Loading Loading @@ -331,6 +356,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { } private fun changeToEditMode() { if (note == null || note.remoteId == null) { Log.d(TAG, "note is null, cant edit") return } toggleLoadingUI(true) val updateDisposable = Single.just(note.remoteId) .map { remoteId -> Loading Loading @@ -360,9 +390,11 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { private fun toggleLoadingUI(loading: Boolean) { activity?.runOnUiThread { binding.progress.isVisible = loading binding.noteWebview.isVisible = !loading binding.plainEditingFab.isVisible = !loading binding?.run { progress.isVisible = loading noteWebview.isVisible = !loading plainEditingFab.isVisible = !loading } } } Loading @@ -387,21 +419,25 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { @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 val bundle = Bundle().apply { putLong(PARAM_NOTE_ID, noteId) putLong(PARAM_ACCOUNT_ID, accountId) } return NoteDirectEditFragment().apply { arguments = bundle } } @JvmStatic fun newInstanceWithNewNote(newNote: Note?): BaseNoteFragment { val fragment = NoteDirectEditFragment() val args = Bundle() args.putSerializable(PARAM_NEWNOTE, newNote) fragment.arguments = args return fragment val bundle = Bundle().apply { putSerializable(PARAM_NEWNOTE, newNote) } return NoteDirectEditFragment().apply { arguments = bundle } } } }
app/src/main/java/it/niedermann/owncloud/notes/edit/NoteEditFragment.java +25 −10 Original line number Diff line number Diff line Loading @@ -40,6 +40,8 @@ import it.niedermann.owncloud.notes.databinding.FragmentNoteEditBinding; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; import it.niedermann.owncloud.notes.shared.util.DisplayUtils; import kotlin.Unit; import kotlin.jvm.functions.Function0; public class NoteEditFragment extends SearchableBaseNoteFragment { Loading Loading @@ -180,15 +182,24 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { @Override protected void onNoteLoaded(Note note) { super.onNoteLoaded(note); if (binding == null) { return; } if (TextUtils.isEmpty(note.getContent())) { openSoftKeyboard(); } binding.editContent.setMarkdownString(note.getContent()); binding.editContent.setEnabled(true); lifecycleScopeIOJob(() -> { // load potential big note on IO Dispatchers final String content = note.getContent(); final var sp = PreferenceManager.getDefaultSharedPreferences(requireContext().getApplicationContext()); onMainThread(() -> { binding.editContent.setMarkdownString(content); binding.editContent.setEnabled(true); binding.editContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, getFontSizeFromPreferences(requireContext(), sp)); if (sp.getBoolean(getString(R.string.pref_key_font), false)) { binding.editContent.setTypeface(Typeface.MONOSPACE); } Loading @@ -196,6 +207,10 @@ public class NoteEditFragment extends SearchableBaseNoteFragment { if (lastSelection > 0 && binding.editContent.length() >= lastSelection) { binding.editContent.setSelection(lastSelection); } return Unit.INSTANCE; }); return Unit.INSTANCE; }); } private void openSoftKeyboard() { Loading
app/src/main/java/it/niedermann/owncloud/notes/edit/NotePreviewFragment.java +38 −13 Original line number Diff line number Diff line Loading @@ -34,12 +34,16 @@ 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.owncloud.android.lib.common.utils.Log_OC; import it.niedermann.owncloud.notes.R; import it.niedermann.owncloud.notes.branding.BrandingUtil; import it.niedermann.owncloud.notes.databinding.FragmentNotePreviewBinding; import it.niedermann.owncloud.notes.persistence.entity.Note; import it.niedermann.owncloud.notes.shared.model.ISyncCallback; import it.niedermann.owncloud.notes.shared.util.SSOUtil; import kotlin.Unit; import kotlin.jvm.functions.Function0; public class NotePreviewFragment extends SearchableBaseNoteFragment implements OnRefreshListener { Loading Loading @@ -135,12 +139,27 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O super.onNoteLoaded(note); noteLoaded = true; registerInternalNoteLinkHandler(); changedText = note.getContent(); binding.singleNoteContent.setMarkdownString(note.getContent(), setScrollY); binding.singleNoteContent.getMarkdownString().observe(requireActivity(), (newContent) -> { lifecycleScopeIOJob(() -> { final String content = note.getContent(); changedText = content; onMainThread(() -> { binding.singleNoteContent.setMarkdownString(content, setScrollY); final var activity = getActivity(); if (activity == null) { return Unit.INSTANCE; } binding.singleNoteContent.getMarkdownString().observe(activity, (newContent) -> { changedText = newContent.toString(); saveNote(null); }); return Unit.INSTANCE; }); return Unit.INSTANCE; }); } protected void registerInternalNoteLinkHandler() { Loading Loading @@ -176,21 +195,27 @@ public class NotePreviewFragment extends SearchableBaseNoteFragment implements O public void onRefresh() { if (noteLoaded && repo.isSyncPossible() && SSOUtil.isConfigured(getContext())) { binding.swiperefreshlayout.setRefreshing(true); executor.submit(() -> { lifecycleScopeIOJob(() -> { try { final var account = repo.getAccountByName(SingleAccountHelper.getCurrentSingleSignOnAccount(requireContext()).name); repo.addCallbackPull(account, () -> executor.submit(() -> { repo.addCallbackPull(account, () -> { note = repo.getNoteById(note.getId()); changedText = note.getContent(); requireActivity().runOnUiThread(() -> { binding.singleNoteContent.setMarkdownString(note.getContent()); final String content = note.getContent(); changedText = content; onMainThread(() -> { binding.singleNoteContent.setMarkdownString(content); binding.swiperefreshlayout.setRefreshing(false); return Unit.INSTANCE; }); })); }); repo.scheduleSync(account, false); } catch (NextcloudFilesAppAccountNotFoundException | NoCurrentAccountSelectedException e) { e.printStackTrace(); } catch (Exception e) { Log_OC.e(TAG, "onRefresh exception: " + e); } return Unit.INSTANCE; }); } else { binding.swiperefreshlayout.setRefreshing(false); Loading