Compare commits
No commits in common. "5c43e127826ab564a6ecccf9ba2aaad96a90b034" and "0dd19f07210aea1f1d7011445995ba910a3d4d1e" have entirely different histories.
5c43e12782
...
0dd19f0721
@ -2,7 +2,7 @@
|
|||||||
# Keep the served companion APK in sync with main on every push.
|
# Keep the served companion APK in sync with main on every push.
|
||||||
#
|
#
|
||||||
# When a push to main includes Android changes, rebuild the APK, refresh
|
# When a push to main includes Android changes, rebuild the APK, refresh
|
||||||
# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask
|
# neode-ui/public/packages/archipelago-companion.apk.zip, commit it, and ask
|
||||||
# you to push again (so the refreshed APK rides along in the same push).
|
# you to push again (so the refreshed APK rides along in the same push).
|
||||||
#
|
#
|
||||||
# Enable once per clone: git config core.hooksPath .githooks
|
# Enable once per clone: git config core.hooksPath .githooks
|
||||||
@ -40,7 +40,7 @@ fi
|
|||||||
|
|
||||||
bash scripts/publish-companion-apk.sh || exit 0
|
bash scripts/publish-companion-apk.sh || exit 0
|
||||||
|
|
||||||
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||||
if git diff --cached --quiet -- "$DEST"; then
|
if git diff --cached --quiet -- "$DEST"; then
|
||||||
exit 0 # APK unchanged — nothing to do
|
exit 0 # APK unchanged — nothing to do
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 12
|
versionCode = 10
|
||||||
versionName = "0.4.8"
|
versionName = "0.4.6"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -1,30 +1,16 @@
|
|||||||
package com.archipelago.app.ui.screens
|
package com.archipelago.app.ui.screens
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
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.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.view.ViewGroup
|
||||||
import android.webkit.CookieManager
|
import android.webkit.CookieManager
|
||||||
import android.webkit.URLUtil
|
|
||||||
import android.webkit.ValueCallback
|
|
||||||
import android.webkit.WebChromeClient
|
import android.webkit.WebChromeClient
|
||||||
import android.webkit.WebResourceError
|
import android.webkit.WebResourceError
|
||||||
import android.webkit.WebResourceRequest
|
import android.webkit.WebResourceRequest
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
import android.webkit.WebViewClient
|
import android.webkit.WebViewClient
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.BackHandler
|
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.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@ -42,22 +28,10 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
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.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.CloudOff
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material.icons.filled.Public
|
import androidx.compose.material.icons.filled.OpenInBrowser
|
||||||
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.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
@ -143,159 +117,6 @@ private fun WebView.applyArchipelagoSettings() {
|
|||||||
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
|
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")
|
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
||||||
@Composable
|
@Composable
|
||||||
fun WebViewScreen(
|
fun WebViewScreen(
|
||||||
@ -308,37 +129,6 @@ fun WebViewScreen(
|
|||||||
var hasError by remember { mutableStateOf(false) }
|
var hasError by remember { mutableStateOf(false) }
|
||||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
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.
|
// A node app that refused iframing, opened in a local WebView overlay.
|
||||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
||||||
// while this is shown, so closing it returns instantly with no reload.
|
// while this is shown, so closing it returns instantly with no reload.
|
||||||
@ -427,7 +217,6 @@ fun WebViewScreen(
|
|||||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
applyArchipelagoSettings()
|
applyArchipelagoSettings()
|
||||||
enableFileDownloads(context)
|
|
||||||
settings.apply {
|
settings.apply {
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
// Let JS open windows without a synchronous user-gesture
|
// Let JS open windows without a synchronous user-gesture
|
||||||
@ -539,13 +328,6 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
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"
|
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||||
// for an app that can't be iframed. Capture the target
|
// for an app that can't be iframed. Capture the target
|
||||||
// URL via a throwaway WebView and route it ourselves.
|
// URL via a throwaway WebView and route it ourselves.
|
||||||
@ -640,33 +422,17 @@ fun WebViewScreen(
|
|||||||
url = target,
|
url = target,
|
||||||
serverUrl = serverUrl,
|
serverUrl = serverUrl,
|
||||||
onClose = { inAppUrl = null },
|
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
|
* 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
|
* shown in an iframe. Loads the app in a local WebView with a minimal top bar
|
||||||
* (app icon + progress) and a bottom control bar matching the web app-session
|
* (close + title + escalate-to-real-browser). Same-host navigation stays here;
|
||||||
* mobile bar. Same-host navigation stays here; any genuinely external link
|
* any genuinely external link escapes to the phone's browser.
|
||||||
* escapes to the phone's browser.
|
|
||||||
*/
|
*/
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
@Composable
|
@Composable
|
||||||
@ -674,22 +440,12 @@ private fun InAppBrowser(
|
|||||||
url: String,
|
url: String,
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onShowFileChooser: (ValueCallback<Array<Uri>>?, WebChromeClient.FileChooserParams?) -> Boolean,
|
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
var browser by remember { mutableStateOf<WebView?>(null) }
|
||||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
||||||
var progress by remember { mutableIntStateOf(0) }
|
var progress by remember { mutableIntStateOf(0) }
|
||||||
var loading by remember { mutableStateOf(true) }
|
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.
|
// Back: walk the in-app history first, then close the overlay.
|
||||||
BackHandler {
|
BackHandler {
|
||||||
@ -700,166 +456,99 @@ private fun InAppBrowser(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(SurfaceBlack),
|
.background(SurfaceBlack)
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
) {
|
) {
|
||||||
// Content area: the app WebView, with a thin progress-bar loader pinned
|
Row(
|
||||||
// to the top edge while the page loads.
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.windowInsetsPadding(WindowInsets.statusBars),
|
.height(48.dp)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
AndroidView(
|
IconButton(onClick = onClose) {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Icon(
|
||||||
factory = { ctx ->
|
imageVector = Icons.Default.Close,
|
||||||
WebView(ctx).apply {
|
contentDescription = stringResource(R.string.close),
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
tint = TextPrimary,
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
)
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
}
|
||||||
)
|
Text(
|
||||||
isVerticalScrollBarEnabled = false
|
text = title,
|
||||||
isHorizontalScrollBarEnabled = false
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = TextPrimary,
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
maxLines = 1,
|
||||||
applyArchipelagoSettings()
|
overflow = TextOverflow.Ellipsis,
|
||||||
enableFileDownloads(ctx)
|
modifier = Modifier.weight(1f),
|
||||||
|
|
||||||
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<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
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
|
||||||
// Loading splash — app icon + progress bar, covering the white
|
Icon(
|
||||||
// first-paint flash until the app's own UI is ready.
|
imageVector = Icons.Default.OpenInBrowser,
|
||||||
if (loading) {
|
contentDescription = stringResource(R.string.open_in_browser),
|
||||||
Column(
|
tint = TextMuted,
|
||||||
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
|
AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) {
|
||||||
// evenly-spaced buttons over a translucent dark bar with a hairline top
|
LinearProgressIndicator(
|
||||||
// border, sitting above the system navigation bar.
|
progress = { progress / 100f },
|
||||||
Column(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier
|
color = BitcoinOrange,
|
||||||
.fillMaxWidth()
|
trackColor = SurfaceBlack,
|
||||||
.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -23,9 +23,6 @@
|
|||||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||||
<string name="close">Close</string>
|
<string name="close">Close</string>
|
||||||
<string name="open_in_browser">Open in browser</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_label">Server Name (optional)</string>
|
||||||
<string name="server_name_placeholder">My Archipelago</string>
|
<string name="server_name_placeholder">My Archipelago</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Build the Android companion app and publish it as the served download
|
# Build the Android companion app and publish it as the served download
|
||||||
# (neode-ui/public/packages/archipelago-companion.apk — a plain APK a phone can
|
# (neode-ui/public/packages/archipelago-companion.apk.zip), then commit + push.
|
||||||
# install straight from the link), then commit + push.
|
|
||||||
#
|
#
|
||||||
# Use this INSTEAD of `git push` when shipping the companion app, so the
|
# Use this INSTEAD of `git push` when shipping the companion app, so the
|
||||||
# downloadable APK on the node always matches what's on main.
|
# downloadable APK on the node always matches what's on main.
|
||||||
@ -18,8 +17,7 @@ export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
|
|||||||
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
|
||||||
|
|
||||||
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||||
OLD_ZIP="neode-ui/public/packages/archipelago-companion.apk.zip"
|
|
||||||
|
|
||||||
echo "==> Building debug APK"
|
echo "==> Building debug APK"
|
||||||
( cd Android && ./gradlew :app:assembleDebug --console=plain -q )
|
( cd Android && ./gradlew :app:assembleDebug --console=plain -q )
|
||||||
@ -27,11 +25,8 @@ echo "==> Building debug APK"
|
|||||||
|
|
||||||
echo "==> Publishing -> $DEST"
|
echo "==> Publishing -> $DEST"
|
||||||
mkdir -p "$(dirname "$DEST")"
|
mkdir -p "$(dirname "$DEST")"
|
||||||
cp "$APK" "$DEST"
|
rm -f "$DEST"
|
||||||
# Drop the legacy zipped artifact so the served download is the raw APK only.
|
( cd "$(dirname "$APK")" && zip -j -q "$ROOT/$DEST" "$(basename "$APK")" )
|
||||||
if [ -f "$OLD_ZIP" ]; then
|
|
||||||
git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP"
|
|
||||||
fi
|
|
||||||
|
|
||||||
git add "$DEST"
|
git add "$DEST"
|
||||||
if git diff --cached --quiet; then
|
if git diff --cached --quiet; then
|
||||||
|
|||||||
Binary file not shown.
@ -82,7 +82,7 @@ const STORAGE_KEY = 'neode_companion_intro_seen'
|
|||||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||||
// host to resolve). Points at the companion APK hosted on the 146 release server
|
// host to resolve). Points at the companion APK hosted on the 146 release server
|
||||||
// (publicly reachable) rather than the local node's /packages copy.
|
// (publicly reachable) rather than the local node's /packages copy.
|
||||||
const DEFAULT_DOWNLOAD_URL = 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
|
const DEFAULT_DOWNLOAD_URL = 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk.zip'
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const qrDataUrl = ref('')
|
const qrDataUrl = ref('')
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build the Archipelago companion debug APK and stage it as the served download
|
# Build the Archipelago companion debug APK and stage it as the served download
|
||||||
# at neode-ui/public/packages/archipelago-companion.apk (a plain APK, so a phone
|
# at neode-ui/public/packages/archipelago-companion.apk.zip.
|
||||||
# can install it straight from the link — no unzip step).
|
|
||||||
#
|
#
|
||||||
# Run manually, or automatically via the pre-push hook (.githooks/pre-push).
|
# Run manually, or automatically via the pre-push hook (.githooks/pre-push).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -22,16 +21,15 @@ echo "publish-companion-apk: building debug APK…" >&2
|
|||||||
( cd Android && JAVA_HOME="$JAVA" ANDROID_HOME="$SDK" ./gradlew -q :app:assembleDebug )
|
( cd Android && JAVA_HOME="$JAVA" ANDROID_HOME="$SDK" ./gradlew -q :app:assembleDebug )
|
||||||
|
|
||||||
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
DEST="neode-ui/public/packages/archipelago-companion.apk"
|
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
|
||||||
OLD_ZIP="neode-ui/public/packages/archipelago-companion.apk.zip"
|
|
||||||
mkdir -p "$(dirname "$DEST")"
|
mkdir -p "$(dirname "$DEST")"
|
||||||
|
|
||||||
cp "$APK" "$DEST"
|
TMP="$(mktemp -d)"
|
||||||
|
cp "$APK" "$TMP/app-debug.apk"
|
||||||
# Drop the legacy zipped artifact so the served download is the raw APK only.
|
# -X drops platform-specific extra fields for a stabler archive.
|
||||||
if [ -f "$OLD_ZIP" ]; then
|
( cd "$TMP" && zip -q -X archipelago-companion.apk.zip app-debug.apk )
|
||||||
git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP"
|
cp "$TMP/archipelago-companion.apk.zip" "$DEST"
|
||||||
fi
|
rm -rf "$TMP"
|
||||||
|
|
||||||
git add "$DEST"
|
git add "$DEST"
|
||||||
echo "publish-companion-apk: staged $DEST" >&2
|
echo "publish-companion-apk: staged $DEST" >&2
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user