diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 36a5b644..1f7cba1b 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 10 - versionName = "0.4.6" + versionCode = 12 + versionName = "0.4.8" vectorDrawables { useSupportLibrary = true 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..abb0dae2 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,16 +1,30 @@ 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.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 @@ -28,10 +42,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size 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.filled.Close import androidx.compose.material.icons.filled.CloudOff -import androidx.compose.material.icons.filled.OpenInBrowser +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.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator @@ -117,6 +143,159 @@ 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( @@ -129,6 +308,37 @@ fun WebViewScreen( var hasError by remember { mutableStateOf(false) } var webView by remember { mutableStateOf(null) } + // System file-picker plumbing for . onShowFileChooser + // stashes the WebView's callback here; the launcher result delivers the + // picked URIs back to it (or null on cancel, so the input doesn't hang). + val pendingFileCallback = remember { mutableStateOf>?>(null) } + val fileChooserLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + val cb = pendingFileCallback.value + pendingFileCallback.value = null + cb?.onReceiveValue( + WebChromeClient.FileChooserParams.parseResult(result.resultCode, result.data), + ) + } + val showFileChooser: (ValueCallback>?, WebChromeClient.FileChooserParams?) -> Boolean = + { callback, params -> + pendingFileCallback.value?.onReceiveValue(null) + pendingFileCallback.value = callback + try { + val intent = params?.createIntent() + ?: Intent(Intent.ACTION_GET_CONTENT).apply { + type = "*/*" + addCategory(Intent.CATEGORY_OPENABLE) + } + fileChooserLauncher.launch(intent) + true + } catch (_: Exception) { + pendingFileCallback.value = null + false + } + } + // A node app that refused iframing, opened in a local WebView overlay. // null = no overlay. The kiosk WebView underneath stays alive (and warm) // while this is shown, so closing it returns instantly with no reload. @@ -217,6 +427,7 @@ fun WebViewScreen( cookieManager.setAcceptThirdPartyCookies(this, true) applyArchipelagoSettings() + enableFileDownloads(context) settings.apply { setSupportMultipleWindows(true) // enables onCreateWindow for window.open // Let JS open windows without a synchronous user-gesture @@ -328,6 +539,13 @@ fun WebViewScreen( loadProgress = newProgress } + // Open the system file browser for . + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams?, + ): Boolean = showFileChooser(filePathCallback, fileChooserParams) + // window.open() — e.g. the kiosk's "Open in new tab" // for an app that can't be iframed. Capture the target // URL via a throwaway WebView and route it ourselves. @@ -422,17 +640,33 @@ fun WebViewScreen( url = target, serverUrl = serverUrl, onClose = { inAppUrl = null }, + onShowFileChooser = showFileChooser, ) } } } } +/** One control in the in-app browser's bottom bar — sized and styled to match + * the web app-session mobile bar (52dp tap target, 24dp glyph, muted white). */ +@Composable +private fun FooterButton(iconRes: Int, descRes: Int, onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(52.dp)) { + Icon( + painter = painterResource(iconRes), + contentDescription = stringResource(descRes), + tint = Color.White.copy(alpha = 0.72f), + modifier = Modifier.size(24.dp), + ) + } +} + /** * 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 loading splash + * (app icon + progress) and a bottom control bar matching the web app-session + * mobile bar. Same-host navigation stays here; any genuinely external link + * escapes to the phone's browser. */ @SuppressLint("SetJavaScriptEnabled") @Composable @@ -440,12 +674,22 @@ private fun InAppBrowser( url: String, serverUrl: String, onClose: () -> Unit, + onShowFileChooser: (ValueCallback>?, WebChromeClient.FileChooserParams?) -> Boolean, ) { val context = LocalContext.current var browser by remember { mutableStateOf(null) } var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } var progress by remember { mutableIntStateOf(0) } var loading by remember { mutableStateOf(true) } + // The launched app's icon, shown on the loading splash. Seeded by a + // best-effort favicon fetch, then upgraded to the real icon once the + // WebView reports onReceivedIcon. + var appIcon by remember { mutableStateOf(null) } + + LaunchedEffect(url) { + val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) } + if (fetched != null && appIcon == null) appIcon = fetched + } // Back: walk the in-app history first, then close the overlay. BackHandler { @@ -456,99 +700,166 @@ private fun InAppBrowser( Column( modifier = Modifier .fillMaxSize() - .background(SurfaceBlack) - .windowInsetsPadding(WindowInsets.safeDrawing), + .background(SurfaceBlack), ) { - Row( + // Content area: the app WebView, with a thin progress-bar loader pinned + // to the top edge while the page loads. + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.statusBars), + ) { + 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() + enableFileDownloads(ctx) + + 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) appIcon = icon + } + + override fun onShowFileChooser( + webView: WebView?, + filePathCallback: ValueCallback>?, + fileChooserParams: FileChooserParams?, + ): Boolean = onShowFileChooser(filePathCallback, fileChooserParams) + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { + loading = true + if (favicon != null) appIcon = favicon + } + + 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) + } + }, + ) + + // Loading splash — app icon + progress bar, covering the white + // first-paint flash until the app's own UI is ready. + if (loading) { + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + val icon = appIcon + if (icon != null) { + Image( + bitmap = icon.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(14.dp)), + ) + } else { + Icon( + imageVector = Icons.Default.Public, + contentDescription = null, + tint = TextMuted, + modifier = Modifier.size(64.dp), + ) + } + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(20.dp)) + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.fillMaxWidth(0.55f), + color = BitcoinOrange, + trackColor = SurfaceBlack, + ) + } + } + } + + // Mobile footer controls — matched to the web app-session bar: five + // evenly-spaced buttons over a translucent dark bar with a hairline top + // border, sitting above the system navigation bar. + Column( modifier = Modifier .fillMaxWidth() - .height(48.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, + .background(Color(0xF2141414)), ) { - IconButton(onClick = onClose) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.close), - tint = TextPrimary, - ) - } - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - color = TextPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0x14FFFFFF)), ) - 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) + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .heightIn(min = 64.dp) + .padding(vertical = 6.dp, horizontal = 8.dp), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + FooterButton(R.drawable.ic_nav_back, R.string.back) { + browser?.let { if (it.canGoBack()) it.goBack() } } - }, - ) + FooterButton(R.drawable.ic_nav_forward, R.string.forward) { + browser?.let { if (it.canGoForward()) it.goForward() } + } + FooterButton(R.drawable.ic_nav_refresh, R.string.refresh) { + browser?.reload() + } + FooterButton(R.drawable.ic_nav_newtab, R.string.open_in_browser) { + openExternalUrl(context, browser?.url ?: url) + } + FooterButton(R.drawable.ic_nav_close, R.string.close) { + onClose() + } + } + } } } diff --git a/Android/app/src/main/res/drawable/ic_nav_back.xml b/Android/app/src/main/res/drawable/ic_nav_back.xml new file mode 100644 index 00000000..fb5842af --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_back.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_close.xml b/Android/app/src/main/res/drawable/ic_nav_close.xml new file mode 100644 index 00000000..3620ff4c --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_close.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_forward.xml b/Android/app/src/main/res/drawable/ic_nav_forward.xml new file mode 100644 index 00000000..89757edb --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_forward.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_newtab.xml b/Android/app/src/main/res/drawable/ic_nav_newtab.xml new file mode 100644 index 00000000..e1c4eb2a --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_newtab.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/drawable/ic_nav_refresh.xml b/Android/app/src/main/res/drawable/ic_nav_refresh.xml new file mode 100644 index 00000000..27766e42 --- /dev/null +++ b/Android/app/src/main/res/drawable/ic_nav_refresh.xml @@ -0,0 +1,12 @@ + + + diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index a6fadd29..68305488 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -23,6 +23,9 @@ Use your phone as a keyboard and mouse for the kiosk Close Open in browser + Back + Forward + Refresh Server Name (optional) My Archipelago