From 96f9b92b7388a318cff7d9ee646197ec415fecda Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 4 Nov 2026 12:38:36 +0100 Subject: [PATCH 1/2] Extracted webview from fragment to keep it running: the fragment can attach to the running/lazy-loaded wv and load the page Signed-off-by: daniele-verducci --- .../notes/edit/NoteDirectEditFragment.kt | 148 +++--------------- .../DirectEditingMobileInterface.kt | 35 +++++ .../DirectEditingWebViewClient.kt | 70 +++++++++ .../DirectEditingWebViewProvider.kt | 75 +++++++++ .../res/layout/fragment_note_direct_edit.xml | 6 +- 5 files changed, 207 insertions(+), 127 deletions(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingMobileInterface.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewClient.kt create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewProvider.kt 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 index d649e3c6d..299389636 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt @@ -7,22 +7,13 @@ package it.niedermann.owncloud.notes.edit import android.annotation.SuppressLint -import android.content.ActivityNotFoundException -import android.content.Intent -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 @@ -39,6 +30,9 @@ 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.edit.directeditingwebview.DirectEditingMobileInterface +import it.niedermann.owncloud.notes.edit.directeditingwebview.DirectEditingWebViewClient +import it.niedermann.owncloud.notes.edit.directeditingwebview.DirectEditingWebViewProvider import it.niedermann.owncloud.notes.persistence.ApiProvider import it.niedermann.owncloud.notes.persistence.DirectEditingRepository import it.niedermann.owncloud.notes.persistence.entity.Note @@ -57,6 +51,13 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { private val disposables: DisposableSet = DisposableSet() private var switchToEditPending = false + private var webView: WebView? = null + + private val mobileInterfaceListener = object : DirectEditingMobileInterface.Listener { + override fun onClose() = close() + override fun onShare() = share() + override fun onLoaded() = this@NoteDirectEditFragment.onLoaded() + } val account: SingleSignOnAccount by lazy { SingleAccountHelper.getCurrentSingleSignOnAccount( @@ -85,10 +86,14 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { savedInstanceState: Bundle?, ): View? { Log.d(TAG, "onCreateView() called") - _binding = FragmentNoteDirectEditBinding.inflate(inflater, container, false) + val viewBinding = FragmentNoteDirectEditBinding.inflate(inflater, container, false).also { _binding = it } + webView = DirectEditingWebViewProvider.attach( + viewBinding.noteWebviewContainer, + DirectEditingWebViewClient(runCatching { account.url }.getOrNull(), ::handleLoadError), + mobileInterfaceListener, + ) setupFab() - prepareWebView() - return binding?.root + return viewBinding.root } @SuppressLint("ClickableViewAccessibility") // touch listener only for UI purposes, no need to handle click @@ -98,7 +103,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { ExtendedFabUtil.toggleExtendedOnLongClick(plainEditingFab) // manually detect scroll as we can't get it from the webview (maybe with custom JS?) - noteWebview.setOnTouchListener { _, event -> + webView?.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { scrollStart = event.y.toInt() @@ -121,7 +126,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { private fun switchToPlainEdit() { switchToEditPending = true - binding?.noteWebview?.evaluateJavascript(JS_CLOSE) { result -> + webView?.evaluateJavascript(JS_CLOSE) { result -> val resultWithoutQuotes = result.replace("\"", "") if (resultWithoutQuotes != JS_RESULT_OK) { Log.w(TAG, "Closing via JS failed: $resultWithoutQuotes") @@ -134,7 +139,8 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { override fun onDestroyView() { super.onDestroyView() disposables.dispose() - binding?.noteWebview?.destroy() + binding?.let { DirectEditingWebViewProvider.detach(it.noteWebviewContainer) } + webView = null _binding = null } @@ -143,7 +149,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { val timeoutDisposable = Single.just(Unit) .delay(LOAD_TIMEOUT_SECONDS, TimeUnit.SECONDS) .map { - if (binding?.noteWebview?.isVisible == false) { + if (binding?.noteWebviewContainer?.isVisible == false) { Log.w(TAG, "Editor not loaded after $LOAD_TIMEOUT_SECONDS seconds") handleLoadError() } @@ -208,7 +214,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { if (BuildConfig.DEBUG) { Log.d(TAG, "loadNoteInWebView: url = $url") } - binding?.noteWebview?.loadUrl(url) + webView?.loadUrl(url) } }, { throwable -> handleLoadError() @@ -242,94 +248,6 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { override fun shouldShowToolbar(): Boolean = false - @SuppressLint("SetJavaScriptEnabled") - private fun prepareWebView() { - binding?.noteWebview?.settings?.run { - // enable zoom - setSupportZoom(true) - builtInZoomControls = true - displayZoomControls = false - - // Non-responsive webs are zoomed out when loaded - useWideViewPort = true - loadWithOverviewMode = true - - // user agent - val userAgent = - getString(R.string.user_agent, getString(R.string.app_name), BuildConfig.VERSION_NAME) - userAgentString = userAgent - - // no private data storing - savePassword = false - saveFormData = false - - // disable local file access - allowFileAccess = false - - // enable javascript - javaScriptEnabled = true - 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) - } - } - - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest?, - ): Boolean { - val url = request?.url ?: return false - val scheme = url.scheme?.lowercase() - val accountUrl = runCatching { account.url }.getOrNull() - val staysOnServer = (scheme == "http" || scheme == "https") && - accountUrl != null && url.toString().startsWith(accountUrl) - if (!staysOnServer) { - try { - val intent = Intent(Intent.ACTION_VIEW, url).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(intent) - } catch (e: ActivityNotFoundException) { - Log.w(TAG, "No app available to open $url", e) - } - } - return !staysOnServer - } - } - } - /** * Gets the current content of the EditText field in the UI. * @@ -358,23 +276,6 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { } } - 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") @@ -422,7 +323,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { activity?.runOnUiThread { binding?.run { progress.isVisible = loading - noteWebview.isVisible = !loading + noteWebviewContainer.isVisible = !loading plainEditingFab.isVisible = !loading } } @@ -431,7 +332,6 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { 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 diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingMobileInterface.kt b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingMobileInterface.kt new file mode 100644 index 000000000..061da0b7f --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingMobileInterface.kt @@ -0,0 +1,35 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.edit.directeditingwebview + +import android.webkit.JavascriptInterface + +class DirectEditingMobileInterface { + @Volatile + var listener: Listener? = null + + @JavascriptInterface + fun close() { + listener?.onClose() + } + + @JavascriptInterface + fun share() { + listener?.onShare() + } + + @JavascriptInterface + fun loaded() { + listener?.onLoaded() + } + + interface Listener { + fun onClose() + fun onShare() + fun onLoaded() + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewClient.kt b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewClient.kt new file mode 100644 index 000000000..0cb7a0470 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewClient.kt @@ -0,0 +1,70 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.edit.directeditingwebview + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.net.http.SslError +import android.util.Log +import android.webkit.SslErrorHandler +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import it.niedermann.owncloud.notes.BuildConfig + +class DirectEditingWebViewClient( + private val serverUrl: String?, + private val onMainFrameError: () -> Unit, +) : WebViewClient() { + + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { + super.onReceivedError(view, request, error) + if (request?.isForMainFrame == true) { + onMainFrameError() + } + } + + @SuppressLint("WebViewClientOnReceivedSslError") + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { + if (BuildConfig.DEBUG) { + handler?.proceed() + } else { + super.onReceivedSslError(view, handler, error) + } + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + val url = request?.url ?: return false + if (staysOnServer(url)) return false + view?.let { openExternally(it, url) } + return true + } + + private fun staysOnServer(url: Uri): Boolean { + val scheme = url.scheme?.lowercase() + return (scheme == "http" || scheme == "https") && + serverUrl != null && url.toString().startsWith(serverUrl) + } + + private fun openExternally(view: WebView, url: Uri) { + try { + val intent = Intent(Intent.ACTION_VIEW, url).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + view.context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No app available to open $url", e) + } + } + + companion object { + private const val TAG = "DirectEditingWebViewClient" + } +} diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewProvider.kt b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewProvider.kt new file mode 100644 index 000000000..df8d56014 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/DirectEditingWebViewProvider.kt @@ -0,0 +1,75 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.edit.directeditingwebview + +import android.annotation.SuppressLint +import android.content.Context +import android.content.MutableContextWrapper +import android.view.ViewGroup +import android.webkit.WebSettings +import android.webkit.WebView +import android.webkit.WebViewClient +import it.niedermann.owncloud.notes.BuildConfig +import it.niedermann.owncloud.notes.R + +@SuppressLint("StaticFieldLeak") +object DirectEditingWebViewProvider { + private const val JS_INTERFACE_NAME = "DirectEditingMobileInterface" + + private val mobileInterface = DirectEditingMobileInterface() + private var webView: WebView? = null + + fun attach( + container: ViewGroup, + client: WebViewClient, + listener: DirectEditingMobileInterface.Listener, + ): WebView { + val webView = webView ?: create(container.context.applicationContext).also { webView = it } + (webView.parent as? ViewGroup)?.removeView(webView) + (webView.context as MutableContextWrapper).baseContext = container.context + webView.webViewClient = client + mobileInterface.listener = listener + container.addView(webView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + return webView + } + + @SuppressLint("ClickableViewAccessibility") + fun detach(container: ViewGroup) { + val webView = webView ?: return + if (webView.parent !== container) return + container.removeView(webView) + webView.setOnTouchListener(null) + webView.webViewClient = WebViewClient() + mobileInterface.listener = null + (webView.context as MutableContextWrapper).baseContext = container.context.applicationContext + } + + @SuppressLint("SetJavaScriptEnabled") + private fun create(appContext: Context): WebView = WebView(MutableContextWrapper(appContext)).apply { + settings.run { + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + useWideViewPort = true + loadWithOverviewMode = true + userAgentString = appContext.getString( + R.string.user_agent, + appContext.getString(R.string.app_name), + BuildConfig.VERSION_NAME, + ) + savePassword = false + saveFormData = false + allowFileAccess = false + javaScriptEnabled = true + domStorageEnabled = true + if (BuildConfig.DEBUG) { + cacheMode = WebSettings.LOAD_NO_CACHE + } + } + addJavascriptInterface(mobileInterface, JS_INTERFACE_NAME) + } +} diff --git a/app/src/main/res/layout/fragment_note_direct_edit.xml b/app/src/main/res/layout/fragment_note_direct_edit.xml index 9ea531c40..461fbf8ce 100644 --- a/app/src/main/res/layout/fragment_note_direct_edit.xml +++ b/app/src/main/res/layout/fragment_note_direct_edit.xml @@ -11,9 +11,9 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_height="match_parent"> - @@ -40,7 +40,7 @@ android:visibility="gone" app:backgroundTint="@color/defaultBrand" app:icon="@drawable/ic_notes" - app:layout_anchor="@id/note_webview" + app:layout_anchor="@id/note_webview_container" app:layout_anchorGravity="bottom|end" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" From fce6e49660ee172354c22305c863e4349e8cd4d7 Mon Sep 17 00:00:00 2001 From: daniele-verducci Date: Wed, 4 Nov 2026 15:53:42 +0100 Subject: [PATCH 2/2] Load new direct editing url via OCA.Text.openDirectEditing() Signed-off-by: daniele-verducci --- .../notes/edit/NoteDirectEditFragment.kt | 3 +- .../WebViewOpenDirectEditing.kt | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/WebViewOpenDirectEditing.kt 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 index 299389636..c09339f2b 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/NoteDirectEditFragment.kt @@ -33,6 +33,7 @@ import it.niedermann.owncloud.notes.databinding.FragmentNoteDirectEditBinding import it.niedermann.owncloud.notes.edit.directeditingwebview.DirectEditingMobileInterface import it.niedermann.owncloud.notes.edit.directeditingwebview.DirectEditingWebViewClient import it.niedermann.owncloud.notes.edit.directeditingwebview.DirectEditingWebViewProvider +import it.niedermann.owncloud.notes.edit.directeditingwebview.openDirectEditing import it.niedermann.owncloud.notes.persistence.ApiProvider import it.niedermann.owncloud.notes.persistence.DirectEditingRepository import it.niedermann.owncloud.notes.persistence.entity.Note @@ -214,7 +215,7 @@ class NoteDirectEditFragment : BaseNoteFragment(), Branded { if (BuildConfig.DEBUG) { Log.d(TAG, "loadNoteInWebView: url = $url") } - webView?.loadUrl(url) + webView?.openDirectEditing(url) } }, { throwable -> handleLoadError() diff --git a/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/WebViewOpenDirectEditing.kt b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/WebViewOpenDirectEditing.kt new file mode 100644 index 000000000..46b60c572 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/edit/directeditingwebview/WebViewOpenDirectEditing.kt @@ -0,0 +1,37 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.edit.directeditingwebview + +import android.util.Log +import android.webkit.WebView +import org.json.JSONObject + +private const val TAG = "WebViewOpenDirectEditing" +private const val JS_RESULT_OK = "ok" + +fun WebView.openDirectEditing(url: String) { + if (this.url == null) { + loadUrl(url) + return + } + evaluateJavascript(openDirectEditingScript(url)) { result -> + if (result?.trim('"') == JS_RESULT_OK) return@evaluateJavascript + Log.w(TAG, "Reusing the editor failed ($result), reloading the page") + loadUrl(url) + } +} + +// language=js +private fun openDirectEditingScript(url: String) = """ + (function () { + if (typeof OCA === "undefined" || !OCA.Text || typeof OCA.Text.openDirectEditing !== "function") { + return "openDirectEditing not available"; + } + OCA.Text.openDirectEditing(${JSONObject.quote(url)}); + return "$JS_RESULT_OK"; + })(); +""".trimIndent()