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(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. @@ -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 . - 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. @@ -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 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>?, 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 favicon by remember { mutableStateOf(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(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 (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>?, - 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, + ) } } } diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 26e5d94d..4c7bf79a 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -69,12 +69,12 @@
-
- - - - -
+