From 2a249b8a489ad2b97f0732fdc270b5d56fa26232 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 23 Jun 2026 03:48:58 -0400 Subject: [PATCH] feat(android): companion in-app WebView footer controls + loader; shared debug key; v0.4.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - InAppBrowser now has a bottom control bar (back/forward/reload/open-in-browser/ close) mirroring the web mobile footer, plus a centered loading screen (app favicon + progress bar) instead of a bare top bar over black. - Commit a repo-dedicated debug keystore and pin signingConfigs.debug to it so every machine — and the published companion download — signs debug builds with the SAME key (fixes "App not installed" signature-mismatch on update). Force v1+v2. - Bump versionCode 10→11, versionName 0.4.6→0.4.7. Co-Authored-By: Claude Opus 4.8 (1M context) --- Android/.gitignore | 5 + Android/app/build.gradle.kts | 24 +- Android/app/debug.keystore | Bin 0 -> 2666 bytes .../app/ui/screens/WebViewScreen.kt | 239 ++++++++++++------ 4 files changed, 185 insertions(+), 83 deletions(-) create mode 100644 Android/app/debug.keystore diff --git a/Android/.gitignore b/Android/.gitignore index 9262b1ed..4cac341c 100644 --- a/Android/.gitignore +++ b/Android/.gitignore @@ -14,3 +14,8 @@ local.properties *.aab *.jks *.keystore +# Exception: the repo-dedicated *debug* keystore is committed on purpose so every +# machine (and the published companion download) signs debug builds identically — +# updates then install over the top without an uninstall. Debug keys are not +# secret (well-known password "android"); never commit a real release keystore. +!/app/debug.keystore diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 36a5b644..84ba81f9 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,20 +11,40 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 10 - versionName = "0.4.6" + versionCode = 11 + versionName = "0.4.7" vectorDrawables { useSupportLibrary = true } } + signingConfigs { + // Repo-dedicated debug keystore (committed at app/debug.keystore) so every + // machine — and the published companion download — signs debug builds with + // the SAME key. Without this, Gradle falls back to each machine's + // ~/.android/debug.keystore, so a build from a different machine has a + // different signature and the phone rejects the update ("App not installed"). + getByName("debug") { + storeFile = file("debug.keystore") + storePassword = "android" + keyAlias = "androiddebugkey" + keyPassword = "android" + // Force both legacy JAR (v1) and APK Signature Scheme v2. AGP drops v1 + // for minSdk>=24, but some OEM package installers (e.g. Samsung) reject + // a v2-only sideload with "App not installed" — keep v1 for max compat. + enableV1Signing = true + enableV2Signing = true + } + } + buildTypes { debug { // Separate app ID so a debug/test build installs alongside the // release app instead of colliding on signature. applicationIdSuffix = ".debug" versionNameSuffix = "-debug" + signingConfig = signingConfigs.getByName("debug") } release { isMinifyEnabled = true diff --git a/Android/app/debug.keystore b/Android/app/debug.keystore new file mode 100644 index 0000000000000000000000000000000000000000..d99c47cff89f464f8caeb926d45dfec4bdbebf2f GIT binary patch literal 2666 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 } var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } + var favicon by remember { mutableStateOf(null) } var progress by remember { mutableIntStateOf(0) } var loading by remember { mutableStateOf(true) } + var canGoBack by remember { mutableStateOf(false) } + var canGoForward by remember { mutableStateOf(false) } // Back: walk the in-app history first, then close the overlay. BackHandler { @@ -459,13 +473,152 @@ private fun InAppBrowser( .background(SurfaceBlack) .windowInsetsPadding(WindowInsets.safeDrawing), ) { + // WebView + loading overlay fill the area above the bottom control bar. + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + applyArchipelagoSettings() + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progress = newProgress + } + + override fun onReceivedTitle(view: WebView?, t: String?) { + if (!t.isNullOrBlank()) title = t + } + + override fun onReceivedIcon(view: WebView?, icon: Bitmap?) { + if (icon != null) favicon = icon + } + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { + loading = true + } + + override fun onPageFinished(view: WebView?, u: String?) { + loading = false + canGoBack = view?.canGoBack() == true + canGoForward = view?.canGoForward() == true + } + + override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) { + canGoBack = view?.canGoBack() == true + canGoForward = view?.canGoForward() == true + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + val u = request?.url?.toString() ?: return false + // Stay in the overlay for same-node navigation; + // hand genuinely external links to the real browser. + if (isSameHost(u, serverUrl)) return false + openExternalUrl(ctx, u) + return true + } + } + + browser = this + loadUrl(url) + } + }, + ) + + // Centered loading screen — app favicon (or spinner) + title + bar. + if (loading) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)), + contentAlignment = Alignment.Center, + ) { + val fav = favicon + if (fav != null) { + Image( + bitmap = fav.asImageBitmap(), + contentDescription = title, + modifier = Modifier.fillMaxSize(), + ) + } else { + CircularProgressIndicator(color = BitcoinOrange) + } + } + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.width(220.dp), + color = BitcoinOrange, + trackColor = TextMuted.copy(alpha = 0.2f), + ) + } + } + } + + // Bottom control bar — mirrors the web mobile-iframe footer. Row( modifier = Modifier .fillMaxWidth() - .height(48.dp) - .padding(horizontal = 4.dp), + .height(56.dp) + .background(SurfaceBlack) + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { + IconButton(onClick = { browser?.goBack() }, enabled = canGoBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = if (canGoBack) TextPrimary else TextMuted.copy(alpha = 0.4f), + ) + } + IconButton(onClick = { browser?.goForward() }, enabled = canGoForward) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Forward", + tint = if (canGoForward) TextPrimary else TextMuted.copy(alpha = 0.4f), + ) + } + IconButton(onClick = { browser?.reload() }) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Reload", + tint = TextPrimary, + ) + } + IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = stringResource(R.string.open_in_browser), + tint = TextPrimary, + ) + } IconButton(onClick = onClose) { Icon( imageVector = Icons.Default.Close, @@ -473,82 +626,6 @@ private fun InAppBrowser( tint = TextPrimary, ) } - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) { - Icon( - imageVector = Icons.Default.OpenInBrowser, - contentDescription = stringResource(R.string.open_in_browser), - tint = TextMuted, - ) - } } - - AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) { - LinearProgressIndicator( - progress = { progress / 100f }, - modifier = Modifier.fillMaxWidth(), - color = BitcoinOrange, - trackColor = SurfaceBlack, - ) - } - - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { ctx -> - WebView(ctx).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - isVerticalScrollBarEnabled = false - isHorizontalScrollBarEnabled = false - - CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) - applyArchipelagoSettings() - - webChromeClient = object : WebChromeClient() { - override fun onProgressChanged(view: WebView?, newProgress: Int) { - progress = newProgress - } - - override fun onReceivedTitle(view: WebView?, t: String?) { - if (!t.isNullOrBlank()) title = t - } - } - - webViewClient = object : WebViewClient() { - override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { - loading = true - } - - override fun onPageFinished(view: WebView?, u: String?) { - loading = false - } - - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest?, - ): Boolean { - val u = request?.url?.toString() ?: return false - // Stay in the overlay for same-node navigation; - // hand genuinely external links to the real browser. - if (isSameHost(u, serverUrl)) return false - openExternalUrl(ctx, u) - return true - } - } - - browser = this - loadUrl(url) - } - }, - ) } }