diff --git a/android/app/src/main/assets/offline.html b/android/app/src/main/assets/offline.html new file mode 100644 index 000000000..3899c4da7 --- /dev/null +++ b/android/app/src/main/assets/offline.html @@ -0,0 +1,45 @@ + + + + + Memories + + + + + + + + + diff --git a/android/app/src/main/java/gallery/memories/MainActivity.kt b/android/app/src/main/java/gallery/memories/MainActivity.kt index 8e6eeecb3..c57c07c93 100644 --- a/android/app/src/main/java/gallery/memories/MainActivity.kt +++ b/android/app/src/main/java/gallery/memories/MainActivity.kt @@ -5,10 +5,14 @@ import android.content.ActivityNotFoundException import android.content.Intent import android.content.res.Configuration import android.graphics.Color +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities import android.net.Uri import android.net.http.SslError import android.os.Build.VERSION.SDK_INT import android.os.Bundle +import android.util.Base64 import android.util.Log import android.view.KeyEvent import android.view.View @@ -18,9 +22,12 @@ import android.view.WindowInsetsController import android.view.WindowManager import android.webkit.CookieManager import android.webkit.PermissionRequest +import android.webkit.ServiceWorkerClient +import android.webkit.ServiceWorkerController import android.webkit.SslErrorHandler import android.webkit.ValueCallback import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings @@ -73,6 +80,42 @@ class MainActivity : AppCompatActivity() { private var mNeedRefresh = false + private var mOfflinePageShowing = false + private var mLoadPending = false + private var mReloadOnceOnLoad = false + + private val mNetworkCallback = object : ConnectivityManager.NetworkCallback() { + private var mWasLost = false + + override fun onLost(network: Network) { + mWasLost = true + } + + override fun onAvailable(network: Network) { + // Reload the app if we failed to load it due to being offline + if (mOfflinePageShowing) { + runOnUiThread { loadDefaultUrl() } + } + } + + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { + // onAvailable may fire before the network is actually usable + // (e.g. DNS through a VPN that is still reconnecting), so act + // only when the network is validated + if (!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) return + + if (mOfflinePageShowing) { + runOnUiThread { loadDefaultUrl() } + } else if (mWasLost) { + // The web app cannot rely on the online event (e.g. behind + // an always-on VPN the browser never sees the network go + // away), so refresh the timeline for it + refreshTimeline() + } + mWasLost = false + } + } + private val memoriesRegex = Regex("/apps/memories/.*$") private var host: String? = null @@ -132,6 +175,10 @@ class MainActivity : AppCompatActivity() { // Load JavaScript initializeWebView() + // Reload the app automatically when connectivity returns + getSystemService(ConnectivityManager::class.java) + ?.registerDefaultNetworkCallback(mNetworkCallback) + // Destroy video after 1 seconds (workaround for video not showing on first load) binding.videoView.postDelayed({ binding.videoView.alpha = 1.0f @@ -141,6 +188,8 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() + getSystemService(ConnectivityManager::class.java) + ?.unregisterNetworkCallback(mNetworkCallback) binding.webview.removeAllViews() binding.coordinator.removeAllViews() binding.webview.destroy() @@ -249,6 +298,35 @@ class MainActivity : AppCompatActivity() { } else null } + override fun onPageFinished(view: WebView, url: String?) { + mLoadPending = false + + // A page that commits in a fresh renderer process on the + // offline path can end up never painted even though it loaded + // correctly (observed with the Vanadium WebView). An in-page + // reload runs in the same process and reliably repaints. + if (mReloadOnceOnLoad) { + mReloadOnceOnLoad = false + if (url?.startsWith("http") == true) { + view.evaluateJavascript("location.reload()", null) + } + } + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError + ) { + // Show the offline page if the app itself failed to load, e.g. the + // device is offline and the service worker did not serve a cached copy + if (request.isForMainFrame) { + Log.w(TAG, "onReceivedError: ${error.errorCode} ${error.description}") + mLoadPending = false + showOfflinePage() + } + } + @SuppressLint("WebViewClientOnReceivedSslError") override fun onReceivedSslError( view: WebView?, @@ -264,6 +342,18 @@ class MainActivity : AppCompatActivity() { } } + // Requests from pages controlled by a service worker bypass the + // WebViewClient above, so the local API interception must also be + // registered on the service worker controller + ServiceWorkerController.getInstance().setServiceWorkerClient( + object : ServiceWorkerClient() { + override fun shouldInterceptRequest(request: WebResourceRequest): WebResourceResponse? { + return if (request.url.host == "127.0.0.1") { + nativex.handleRequest(request) + } else null + } + }) + // Use the web chrome client to handle file uploads binding.webview.webChromeClient = object : WebChromeClient() { override fun onPermissionRequest(request: PermissionRequest) { @@ -334,6 +424,22 @@ class MainActivity : AppCompatActivity() { } fun loadDefaultUrl(): Boolean { + // The offline page and the network callbacks may all ask for a + // reload around the same time; re-navigating while the previous + // load is still in flight would abort it + if (mLoadPending) return false + mLoadPending = true + + // Failsafe: never leave the pending flag stuck if the load never + // finishes nor errors out + binding.webview.postDelayed({ mLoadPending = false }, 30000) + + // Coming back from the offline page needs a surface nudge (see + // onPageFinished) + if (mOfflinePageShowing) mReloadOnceOnLoad = true + + mOfflinePageShowing = false + // Load app interface if authenticated host = nativex.http.loadWebView(binding.webview) if (host != null) return true @@ -343,6 +449,43 @@ class MainActivity : AppCompatActivity() { return false } + /** + * Show the offline fallback page. + */ + fun showOfflinePage() { + runOnUiThread { + // Do not reload the page on repeated errors + if (mOfflinePageShowing) return@runOnUiThread + mOfflinePageShowing = true + mReloadOnceOnLoad = true + + // Serve the page from the app origin instead of file:// — + // navigating between file:// and the app URL swaps renderer + // processes, which can leave the WebView blank after reload + val base = nativex.http.baseUrl + if (base != null) { + binding.webview.loadDataWithBaseURL( + base, readAssetInlined("offline.html"), "text/html", "UTF-8", null + ) + } else { + binding.webview.loadUrl("file:///android_asset/offline.html") + } + } + } + + /** + * Read an asset page and inline its stylesheet and logo, so it can be + * served from any origin with loadDataWithBaseURL. + */ + private fun readAssetInlined(name: String): String { + fun read(file: String) = assets.open(file).bufferedReader().use { it.readText() } + val css = read("styles.css") + val logo = Base64.encodeToString(read("memories.svg").toByteArray(), Base64.NO_WRAP) + return read(name) + .replace("""""", "") + .replace("memories.svg", "data:image/svg+xml;base64,$logo") + } + fun initializePlayer(uris: Array, uid: Long, loop: Boolean = false) { if (player != null) { if (playerUid == uid) return diff --git a/android/app/src/main/java/gallery/memories/service/AccountService.kt b/android/app/src/main/java/gallery/memories/service/AccountService.kt index 995944093..1ff6b1ff7 100644 --- a/android/app/src/main/java/gallery/memories/service/AccountService.kt +++ b/android/app/src/main/java/gallery/memories/service/AccountService.kt @@ -130,6 +130,11 @@ class AccountService(private val mCtx: MainActivity, private val mHttp: HttpServ try { val response = mHttp.getApiDescription() + + // The body MUST be parsed before the status check below: a response + // that is not JSON did not come from Nextcloud (e.g. a captive portal + // or an intercepting proxy) and throws here, so a foreign 401 does + // not wipe the stored credentials val body = mHttp.bodyJson(response) // Check status code diff --git a/android/app/src/main/java/gallery/memories/service/HttpService.kt b/android/app/src/main/java/gallery/memories/service/HttpService.kt index db6bfb56f..4ed1622a5 100644 --- a/android/app/src/main/java/gallery/memories/service/HttpService.kt +++ b/android/app/src/main/java/gallery/memories/service/HttpService.kt @@ -38,6 +38,12 @@ class HttpService { val isTrustingAllCertificates: Boolean get() = mTrustAll + /** + * Get the base URL of the server + */ + val baseUrl: String? + get() = mBaseUrl + /** * Check if the HTTP service is logged in */