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