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 1f7cba1b..4fe57e59 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 = 12 - versionName = "0.4.8" + versionCode = 13 + versionName = "0.4.9" 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 abb0dae2..54bcb02a 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 @@ -1,33 +1,21 @@ package com.archipelago.app.ui.screens import android.annotation.SuppressLint -import android.app.DownloadManager -import android.content.ContentValues -import android.content.Context -import android.content.Intent import android.graphics.Bitmap -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.MediaStore -import android.util.Base64 +import android.graphics.BitmapFactory import android.view.ViewGroup import android.webkit.CookieManager -import android.webkit.URLUtil -import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient -import android.widget.Toast import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts 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 @@ -41,29 +29,24 @@ 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 android.graphics.BitmapFactory -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.statusBars 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.Public -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.res.painterResource -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +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 import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -71,6 +54,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 @@ -82,6 +67,8 @@ import com.archipelago.app.ui.theme.BitcoinOrange import com.archipelago.app.ui.theme.SurfaceBlack import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** Open a URL in the phone's default browser (genuinely external links). */ private fun openExternalUrl(context: android.content.Context, url: String) { @@ -143,159 +130,6 @@ private fun WebView.applyArchipelagoSettings() { if (debuggable) WebView.setWebContentsDebuggingEnabled(true) } -private fun mainHandler() = android.os.Handler(android.os.Looper.getMainLooper()) - -private fun toast(context: Context, msg: String) { - mainHandler().post { Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() } -} - -/** Save raw bytes (decoded from a base64 blob/data download) to the device's - * Downloads. Uses MediaStore on API 29+ (no permission needed) and the app's - * external files dir on older devices (also permission-free). */ -private fun saveBase64ToDownloads( - context: Context, - base64: String, - mime: String, - filename: String, -): Boolean { - return try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val name = filename.ifBlank { "download_${System.currentTimeMillis()}" } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val values = ContentValues().apply { - put(MediaStore.Downloads.DISPLAY_NAME, name) - if (mime.isNotBlank()) put(MediaStore.Downloads.MIME_TYPE, mime) - put(MediaStore.Downloads.IS_PENDING, 1) - } - val resolver = context.contentResolver - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) - ?: return false - resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return false - values.clear() - values.put(MediaStore.Downloads.IS_PENDING, 0) - resolver.update(uri, values, null, null) - true - } else { - val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) - if (dir != null && !dir.exists()) dir.mkdirs() - java.io.File(dir, name).outputStream().use { it.write(bytes) } - true - } - } catch (_: Exception) { - false - } -} - -/** Hand an http(s) download to the system DownloadManager, forwarding the - * WebView's session cookies so authenticated node files (e.g. peer-file - * streams) download instead of 401-ing. */ -private fun enqueueHttpDownload( - context: Context, - url: String, - userAgent: String?, - contentDisposition: String?, - mimeType: String?, -) { - val name = URLUtil.guessFileName(url, contentDisposition, mimeType) - val request = DownloadManager.Request(Uri.parse(url)).apply { - setMimeType(mimeType) - CookieManager.getInstance().getCookie(url)?.let { addRequestHeader("Cookie", it) } - if (!userAgent.isNullOrEmpty()) addRequestHeader("User-Agent", userAgent) - setTitle(name) - setDescription("Downloading…") - setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, name) - } else { - setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, name) - } - } - val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - dm.enqueue(request) - toast(context, "Downloading $name") -} - -/** JS that reads a blob: URL the WebView itself created (DownloadManager can't - * fetch blob URLs) and hands the bytes back via the ArchipelagoDownload bridge. */ -private fun blobToBase64Js(blobUrl: String, mime: String, filename: String): String = """ - (function() { - try { - var xhr = new XMLHttpRequest(); - xhr.open('GET', '$blobUrl', true); - xhr.responseType = 'blob'; - xhr.onload = function() { - var reader = new FileReader(); - reader.onloadend = function() { - var dataUrl = reader.result || ''; - var base64 = dataUrl.indexOf(',') >= 0 ? dataUrl.split(',')[1] : ''; - var type = (xhr.response && xhr.response.type) ? xhr.response.type : '$mime'; - ArchipelagoDownload.saveBase64(base64, type, '$filename'); - }; - reader.readAsDataURL(xhr.response); - }; - xhr.send(); - } catch (e) {} - })(); -""".trimIndent() - -/** Enable file downloads for this WebView: a JS bridge for blob/data URLs plus a - * DownloadListener that routes http(s) through DownloadManager and blob/data - * through MediaStore. Safe to call once per WebView. */ -@SuppressLint("JavascriptInterface") -private fun WebView.enableFileDownloads(context: Context) { - addJavascriptInterface( - object { - @android.webkit.JavascriptInterface - fun saveBase64(base64: String, mime: String, filename: String) { - val ok = saveBase64ToDownloads(context, base64, mime, filename) - toast(context, if (ok) "Saved $filename to Downloads" else "Save failed") - } - }, - "ArchipelagoDownload", - ) - setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> - try { - when { - url.startsWith("blob:") -> { - val name = URLUtil.guessFileName(url, contentDisposition, mimeType) - evaluateJavascript(blobToBase64Js(url, mimeType ?: "", name), null) - } - url.startsWith("data:") -> { - val name = URLUtil.guessFileName(url, contentDisposition, mimeType) - val comma = url.indexOf(',') - val b64 = if (comma >= 0) url.substring(comma + 1) else "" - val ok = saveBase64ToDownloads(context, b64, mimeType ?: "", name) - toast(context, if (ok) "Saved $name to Downloads" else "Save failed") - } - else -> enqueueHttpDownload(context, url, userAgent, contentDisposition, mimeType) - } - } catch (_: Exception) { - toast(context, "Download failed") - } - } -} - -/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon - * can be shown on the loading splash before the WebView reports onReceivedIcon - * (which only fires once the page's
has parsed). Blocking — call on IO. */ -private fun fetchFavicon(pageUrl: String): Bitmap? { - return try { - val u = Uri.parse(pageUrl) - val scheme = u.scheme ?: return null - val host = u.host ?: return null - val portPart = if (u.port > 0) ":${u.port}" else "" - val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection() - as java.net.HttpURLConnection).apply { - connectTimeout = 4000 - readTimeout = 4000 - instanceFollowRedirects = true - } - conn.inputStream.use { BitmapFactory.decodeStream(it) } - } catch (_: Exception) { - null - } -} - @SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") @Composable fun WebViewScreen( @@ -308,37 +142,6 @@ fun WebViewScreen( var hasError by remember { mutableStateOf(false) } var webView by remember { mutableStateOf