merge: companion-mobile-ux UX (loader/store-driven launch/icons + android webview) into main

# Conflicts:
#	Android/app/build.gradle.kts
#	Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
#	neode-ui/src/views/apps/appsConfig.ts
This commit is contained in:
archipelago
2026-06-23 14:07:44 -04:00
15 changed files with 435 additions and 373 deletions
@@ -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 <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(
@@ -308,37 +142,6 @@ 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.
@@ -427,7 +230,6 @@ 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
@@ -539,13 +341,6 @@ 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.
@@ -640,33 +435,40 @@ 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),
)
/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon
* can be shown on the loading screen 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 = android.net.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
}
}
/**
* 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 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.
* 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
@@ -674,21 +476,23 @@ 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 favicon by remember { mutableStateOf<Bitmap?>(null) }
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) }
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
// Seed the loading-screen icon immediately from a best-effort favicon
// pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the
// loader shows an icon right away instead of staying blank until the page
// parses its <head> (which is what made the loader look stuck).
LaunchedEffect(url) {
val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) }
if (fetched != null && appIcon == null) appIcon = fetched
if (fetched != null && favicon == null) favicon = fetched
}
// Back: walk the in-app history first, then close the overlay.
@@ -700,16 +504,11 @@ private fun InAppBrowser(
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
// 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),
) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
@@ -723,7 +522,6 @@ private fun InAppBrowser(
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
enableFileDownloads(ctx)
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
@@ -735,24 +533,24 @@ private fun InAppBrowser(
}
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
if (icon != null) appIcon = icon
if (icon != null) favicon = 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?) {
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(
@@ -774,91 +572,93 @@ private fun InAppBrowser(
},
)
// Loading splash — app icon + progress bar, covering the white
// first-paint flash until the app's own UI is ready.
// Centered loading screen — app favicon (or spinner) + title + bar.
if (loading) {
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.padding(32.dp),
.background(SurfaceBlack),
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),
)
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(20.dp))
Spacer(modifier = Modifier.height(18.dp))
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
style = MaterialTheme.typography.bodyLarge,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(0.55f),
modifier = Modifier.width(220.dp),
color = BitcoinOrange,
trackColor = SurfaceBlack,
trackColor = TextMuted.copy(alpha = 0.2f),
)
}
}
}
// 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(
// Bottom control bar — mirrors the web mobile-iframe footer.
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xF2141414)),
.height(56.dp)
.background(SurfaceBlack)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
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()
}
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,
contentDescription = stringResource(R.string.close),
tint = TextPrimary,
)
}
}
}