feat(android): file upload/download + in-app tab redesign

Companion WebView now supports file inputs and downloads, and apps
opened in the in-app tab get a proper loading splash and a footer
control bar matching the web app-session bar.

- onShowFileChooser wired to an ActivityResultLauncher so <input
  type=file> opens the system file browser (kiosk + in-app tab)
- DownloadListener: http(s) via DownloadManager (forwarding session
  cookies), blob: via JS->base64->MediaStore, data: decoded inline
- in-app tab: app-icon + progress loading splash (eager favicon
  fetch, upgraded via onReceivedIcon)
- footer controls (back/forward/refresh/open/close) matched to the
  web AppSession mobile bar, with the same SVG glyphs as drawables
- bump to 0.4.8 (versionCode 12)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-06-23 09:39:50 +01:00
parent 0dd19f0721
commit e825bbed73
8 changed files with 469 additions and 95 deletions

View File

@ -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

View File

@ -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 <head> 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<WebView?>(null) }
// System file-picker plumbing for <input type="file">. 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<ValueCallback<Array<Uri>>?>(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<Array<Uri>>?, 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 <input type="file">.
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
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<Array<Uri>>?, WebChromeClient.FileChooserParams?) -> Boolean,
) {
val context = LocalContext.current
var browser by remember { mutableStateOf<WebView?>(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<Bitmap?>(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,49 +700,16 @@ 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()
.height(48.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
.windowInsetsPadding(WindowInsets.statusBars),
) {
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),
)
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 ->
@ -512,6 +723,7 @@ private fun InAppBrowser(
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
enableFileDownloads(ctx)
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
@ -521,11 +733,22 @@ private fun InAppBrowser(
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<Array<Uri>>?,
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?) {
@ -550,5 +773,93 @@ private fun InAppBrowser(
}
},
)
// 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()
.background(Color(0xF2141414)),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(Color(0x14FFFFFF)),
)
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()
}
}
}
}
}

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M15,19l-7,-7 7,-7"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,18L18,6M6,6l12,12"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,5l7,7 -7,7"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M10,6H6a2,2 0,0 0,-2 2v10a2,2 0,0 0,2 2h10a2,2 0,0 0,2 -2v-4M14,4h6m0,0v6m0,-6L10,14"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,4v6h6M20,20v-6h-6M5.64,15.36A8,8 0,0 0,18.36 18M18.36,8.64A8,8 0,0 0,5.64 6"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>

View File

@ -23,6 +23,9 @@
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
<string name="close">Close</string>
<string name="open_in_browser">Open in browser</string>
<string name="back">Back</string>
<string name="forward">Forward</string>
<string name="refresh">Refresh</string>
<string name="server_name_label">Server Name (optional)</string>
<string name="server_name_placeholder">My Archipelago</string>
</resources>