Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions android/app/src/main/assets/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Memories</title>
<link rel="stylesheet" href="styles.css" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="main" class="container animatable invisible">
<img src="memories.svg" alt="Memories Logo" class="logo" />
<p>
You are offline and no cached copy of the app is available.
Memories will reload automatically when the connection returns.
</p>

<button class="m-button login-button" id="retry">Try again</button>
</div>

<script>
const retryButton = document.getElementById("retry");

retryButton.addEventListener("click", () => {
retryButton.disabled = true;
globalThis.nativex?.reload();
});

// Retry as soon as the WebView sees the network again
window.addEventListener("online", () => globalThis.nativex?.reload());

// Networks can come back without an event (e.g. VPN or DNS recovers
// after the interface is already up), so also retry periodically
setInterval(() => globalThis.nativex?.reload(), 8000);

// Set action bar color
const themeColor = getComputedStyle(
document.documentElement
).getPropertyValue("--theme-color");
globalThis.nativex?.setThemeColor(themeColor, true);

// Make container visible
document.getElementById("main").classList.remove("invisible");
</script>
</body>
</html>
143 changes: 143 additions & 0 deletions android/app/src/main/java/gallery/memories/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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?,
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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("""<link rel="stylesheet" href="styles.css" />""", "<style>$css</style>")
.replace("memories.svg", "data:image/svg+xml;base64,$logo")
}

fun initializePlayer(uris: Array<Uri>, uid: Long, loop: Boolean = false) {
if (player != null) {
if (playerUid == uid) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down