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 00000000..d99c47cf Binary files /dev/null and b/Android/app/debug.keystore differ diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 1ef01c9b..9dbfd687 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -14,6 +14,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -27,11 +28,17 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudOff import androidx.compose.material.icons.filled.OpenInBrowser +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -45,6 +52,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -430,9 +439,11 @@ fun WebViewScreen( /** * Lightweight in-app browser used when the kiosk hands off an app that can't be - * shown in an iframe. Loads the app in a local WebView with a minimal top bar - * (close + title + escalate-to-real-browser). Same-host navigation stays here; - * any genuinely external link escapes to the phone's browser. + * shown in an iframe. Loads the app in a local WebView with a centered loading + * screen (app favicon + progress bar) and a BOTTOM control bar mirroring the + * web mobile-iframe footer (back / forward / reload / open-in-browser / close). + * Same-host navigation stays here; any genuinely external link escapes to the + * phone's browser. */ @SuppressLint("SetJavaScriptEnabled") @Composable @@ -444,8 +455,11 @@ private fun InAppBrowser( val context = LocalContext.current var browser by remember { mutableStateOf(null) } 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) - } - }, - ) } }