Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit a2096a6148
1585 changed files with 335295 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.archipelago.app"
compileSdk = 35
defaultConfig {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 18
versionName = "0.4.14"
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
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
implementation("androidx.activity:activity-compose:1.9.0")
// Compose
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.animation:animation")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.7")
// DataStore for preferences
implementation("androidx.datastore:datastore-preferences:1.1.1")
// WebView
implementation("androidx.webkit:webkit:1.11.0")
// Splash screen
implementation("androidx.core:core-splashscreen:1.0.1")
// OkHttp for WebSocket (remote input)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// CameraX + ZXing (Apache-2.0, on-device, no telemetry) for pairing-QR scanning
implementation("androidx.camera:camera-camera2:1.3.4")
implementation("androidx.camera:camera-lifecycle:1.3.4")
implementation("androidx.camera:camera-view:1.3.4")
implementation("com.google.zxing:core:3.5.3")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
# Keep WebView JavaScript interface
-keepclassmembers class com.archipelago.app.ui.screens.WebViewScreen$* {
public *;
}
# Keep Compose
-dontwarn androidx.compose.**
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Pairing-QR scanner. Camera is optional: manual entry still works without one. -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<application
android:name=".ArchipelagoApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Archipelago"
android:usesCleartextTraffic="true"
tools:targetApi="35">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.Archipelago.Splash"
android:windowSoftInputMode="adjustResize"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Pairing deep link from the web UI's Companion popup:
archipelago://pair?v=1&url=...[&pw=...] (docs/companion-pairing-qr.md) -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="archipelago" android:host="pair" />
</intent-filter>
</activity>
</application>
</manifest>
+492
View File
@@ -0,0 +1,492 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Archipelago</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #000;
color: #f5f5f5;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
padding-top: calc(24px + env(safe-area-inset-top, 0px));
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* --- Intro Screen --- */
#intro {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 20px;
animation: fadeIn 0.6s ease;
}
#intro.hidden, #connect.hidden, #connecting.hidden { display: none; }
.logo-container {
width: 88px;
height: 88px;
border-radius: 20px;
overflow: hidden;
border: 2px solid rgba(255,255,255,0.12);
background: #030202;
}
.logo-container svg { width: 100%; height: 100%; }
.logo-square {
opacity: 0;
animation: squareIn 3s ease-out infinite;
}
@keyframes squareIn {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 1; }
}
.brand-name {
font-size: 12px;
font-weight: 600;
letter-spacing: 6px;
color: #F7931A;
text-transform: uppercase;
}
h1 {
font-size: 28px;
font-weight: 600;
line-height: 1.3;
color: #f5f5f5;
margin-top: 16px;
}
.subtitle {
font-size: 16px;
color: #666;
line-height: 1.6;
max-width: 320px;
}
/* --- Glass Button --- */
.glass-button {
width: 100%;
max-width: 340px;
padding: 16px 24px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.glass-button:active { transform: scale(0.97); }
.glass-button-primary {
background: #F7931A;
color: #000;
}
.glass-button-primary:disabled {
background: rgba(247,147,26,0.3);
color: rgba(0,0,0,0.5);
}
.glass-button-outline {
background: rgba(255,255,255,0.06);
color: #f5f5f5;
border: 1px solid rgba(255,255,255,0.12);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* --- Connect Screen --- */
#connect {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
animation: fadeIn 0.4s ease;
}
.glass-card {
width: 100%;
padding: 20px;
border-radius: 16px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.form-group { margin-bottom: 16px; }
.form-group:last-child { margin-bottom: 0; }
label {
display: block;
font-size: 13px;
font-weight: 500;
color: rgba(255,255,255,0.5);
margin-bottom: 8px;
}
input[type="text"] {
width: 100%;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.12);
background: transparent;
color: #f5f5f5;
font-size: 16px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
input[type="text"]:focus {
border-color: #F7931A;
}
input[type="text"]::placeholder {
color: rgba(255,255,255,0.25);
}
.port-row {
display: flex;
gap: 12px;
align-items: flex-end;
}
.port-input { width: 120px; }
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 0;
}
.toggle-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: rgba(255,255,255,0.7);
}
.toggle-label svg { width: 18px; height: 18px; opacity: 0.5; }
/* Toggle switch */
.toggle {
position: relative;
width: 48px;
height: 28px;
-webkit-appearance: none;
appearance: none;
background: rgba(255,255,255,0.12);
border-radius: 14px;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.toggle:checked { background: #F7931A; }
.toggle::before {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle:checked::before { transform: translateX(20px); }
/* Error */
.error-msg {
width: 100%;
padding: 12px 16px;
border-radius: 12px;
background: rgba(239,68,68,0.12);
border: 1px solid rgba(239,68,68,0.25);
color: #ef4444;
font-size: 14px;
display: none;
}
.error-msg.visible { display: block; }
/* Saved servers */
.saved-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 1px;
color: rgba(255,255,255,0.3);
text-transform: uppercase;
}
.saved-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 12px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
cursor: pointer;
transition: background 0.15s;
}
.saved-item:active { background: rgba(255,255,255,0.08); }
.saved-addr {
font-size: 14px;
color: #f5f5f5;
}
.saved-remove {
background: none;
border: none;
color: rgba(255,255,255,0.3);
font-size: 18px;
cursor: pointer;
padding: 4px 8px;
}
/* Connecting overlay */
#connecting {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
animation: fadeIn 0.3s ease;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid rgba(247,147,26,0.2);
border-top-color: #F7931A;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
/* Hide scrollbar */
::-webkit-scrollbar { display: none; }
</style>
</head>
<body>
<!-- Intro -->
<div id="intro">
<div class="logo-container">
<svg viewBox="0 0 1024 1024" fill="none">
<rect width="1024" height="1024" fill="#030202"/>
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:0ms"/>
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:100ms"/>
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white" class="logo-square" style="animation-delay:200ms"/>
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:300ms"/>
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:400ms"/>
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white" class="logo-square" style="animation-delay:500ms"/>
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:600ms"/>
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:700ms"/>
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:800ms"/>
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white" class="logo-square" style="animation-delay:900ms"/>
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white" class="logo-square" style="animation-delay:1000ms"/>
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white" class="logo-square" style="animation-delay:1100ms"/>
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1200ms"/>
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1300ms"/>
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white" class="logo-square" style="animation-delay:1400ms"/>
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white" class="logo-square" style="animation-delay:1500ms"/>
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1600ms"/>
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1700ms"/>
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white" class="logo-square" style="animation-delay:1800ms"/>
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white" class="logo-square" style="animation-delay:1900ms"/>
</svg>
</div>
<span class="brand-name">Archipelago</span>
<h1>Your Sovereign<br>Personal Server</h1>
<p class="subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</p>
<button class="glass-button glass-button-primary" onclick="showConnect()" style="margin-top:16px">Get Started</button>
</div>
<!-- Connect -->
<div id="connect" class="hidden">
<div class="logo-container" style="width:56px;height:56px;border-radius:14px">
<svg viewBox="0 0 1024 1024" fill="none">
<rect width="1024" height="1024" fill="#030202"/>
<rect x="357.614" y="318" width="71.007" height="70.936" fill="white"/>
<rect x="436.152" y="318" width="72.082" height="70.936" fill="white"/>
<rect x="515.766" y="318" width="72.082" height="70.936" fill="white"/>
<rect x="595.379" y="318" width="71.007" height="70.936" fill="white"/>
<rect x="595.379" y="396.46" width="71.007" height="72.011" fill="white"/>
<rect x="673.917" y="396.46" width="72.083" height="72.011" fill="white"/>
<rect x="278" y="475.994" width="72.083" height="72.012" fill="white"/>
<rect x="357.614" y="475.994" width="71.007" height="72.012" fill="white"/>
<rect x="436.152" y="475.994" width="72.082" height="72.012" fill="white"/>
<rect x="515.766" y="475.994" width="72.082" height="72.012" fill="white"/>
<rect x="595.379" y="475.994" width="71.007" height="72.012" fill="white"/>
<rect x="673.917" y="475.994" width="72.083" height="72.012" fill="white"/>
<rect x="278" y="555.529" width="72.083" height="70.936" fill="white"/>
<rect x="357.614" y="555.529" width="71.007" height="70.936" fill="white"/>
<rect x="595.379" y="555.529" width="71.007" height="70.936" fill="white"/>
<rect x="673.917" y="555.529" width="72.083" height="70.936" fill="white"/>
<rect x="357.614" y="633.989" width="71.007" height="72.011" fill="white"/>
<rect x="436.152" y="633.989" width="72.082" height="72.011" fill="white"/>
<rect x="515.766" y="633.989" width="72.082" height="72.011" fill="white"/>
<rect x="595.379" y="633.989" width="71.007" height="72.011" fill="white"/>
</svg>
</div>
<h1 style="font-size:22px">Connect to Server</h1>
<p class="subtitle" style="font-size:14px">Enter your Archipelago server IP or hostname</p>
<div class="glass-card">
<div class="form-group">
<label>Server Address</label>
<input type="text" id="address" placeholder="192.168.1.100" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-group">
<div class="port-row">
<div class="port-input">
<label>Port (optional)</label>
<input type="text" id="port" placeholder="80" inputmode="numeric" pattern="[0-9]*">
</div>
</div>
</div>
<div class="form-group">
<div class="toggle-row">
<span class="toggle-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
Use HTTPS
</span>
<input type="checkbox" id="https" class="toggle">
</div>
</div>
</div>
<div id="error" class="error-msg"></div>
<button class="glass-button glass-button-primary" id="connectBtn" onclick="doConnect()" disabled>Connect</button>
<div id="savedServers"></div>
</div>
<!-- Connecting -->
<div id="connecting" class="hidden">
<div class="spinner"></div>
<p style="color:rgba(255,255,255,0.6);font-size:14px">Connecting…</p>
</div>
<script>
var STORAGE_KEY = 'archipelago_servers';
var ACTIVE_KEY = 'archipelago_active';
function showConnect() {
document.getElementById('intro').classList.add('hidden');
document.getElementById('connect').classList.remove('hidden');
document.getElementById('address').focus();
renderSaved();
}
// Enable button when address has content
document.getElementById('address').addEventListener('input', function() {
document.getElementById('connectBtn').disabled = !this.value.trim();
});
// Enter to connect
document.getElementById('address').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && this.value.trim()) doConnect();
});
document.getElementById('port').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doConnect();
});
function buildUrl() {
var addr = document.getElementById('address').value.trim();
var port = document.getElementById('port').value.trim();
var https = document.getElementById('https').checked;
var scheme = https ? 'https' : 'http';
var portSuffix = port ? ':' + port : '';
return scheme + '://' + addr + portSuffix;
}
function doConnect() {
var addr = document.getElementById('address').value.trim();
if (!addr) return;
var url = buildUrl();
document.getElementById('connect').classList.add('hidden');
document.getElementById('connecting').classList.remove('hidden');
document.getElementById('error').classList.remove('visible');
// Save and navigate directly — no XHR test needed,
// the WebView error handler catches failures
saveServer(url);
localStorage.setItem(ACTIVE_KEY, url);
AndroidBridge.onConnected(url);
}
function saveServer(url) {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
if (saved.indexOf(url) === -1) saved.push(url);
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
}
function removeServer(url) {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
saved = saved.filter(function(s) { return s !== url; });
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
renderSaved();
}
function connectSaved(url) {
document.getElementById('intro').classList.add('hidden');
document.getElementById('connect').classList.add('hidden');
document.getElementById('connecting').classList.remove('hidden');
localStorage.setItem(ACTIVE_KEY, url);
AndroidBridge.onConnected(url);
}
function renderSaved() {
var saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
var container = document.getElementById('savedServers');
if (!saved.length) { container.innerHTML = ''; return; }
var html = '<p class="saved-title" style="margin-top:8px;margin-bottom:8px">Saved Servers</p>';
saved.forEach(function(url) {
html += '<div class="saved-item" onclick="connectSaved(\'' + url + '\')">' +
'<span class="saved-addr">' + url.replace(/^https?:\/\//, '') + '</span>' +
'<button class="saved-remove" onclick="event.stopPropagation();removeServer(\'' + url + '\')">&times;</button>' +
'</div>';
});
container.innerHTML = html;
}
// On load: check if already connected
(function() {
var active = localStorage.getItem(ACTIVE_KEY);
if (active) {
connectSaved(active);
}
})();
</script>
</body>
</html>
@@ -0,0 +1,5 @@
package com.archipelago.app
import android.app.Application
class ArchipelagoApp : Application()
@@ -0,0 +1,41 @@
package com.archipelago.app
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.archipelago.app.ui.navigation.AppNavHost
import com.archipelago.app.ui.theme.ArchipelagoTheme
import kotlinx.coroutines.flow.MutableStateFlow
class MainActivity : ComponentActivity() {
// Pairing deep link (archipelago://pair?...) from the launch intent or a
// later one (launchMode=singleTask). Consumed by AppNavHost.
private val pendingPairUri = MutableStateFlow<String?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
super.onCreate(savedInstanceState)
pendingPairUri.value = intent?.dataString
setContent {
ArchipelagoTheme {
val pairUri by pendingPairUri.collectAsState()
AppNavHost(
pairUri = pairUri,
onPairUriConsumed = { pendingPairUri.value = null },
)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendingPairUri.value = intent.dataString
}
}
@@ -0,0 +1,200 @@
package com.archipelago.app.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
data class ServerEntry(
val address: String,
val useHttps: Boolean,
val port: String = "",
val password: String = "",
val name: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
fun toUrl(): String {
val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix"
}
fun toWsUrl(): String {
val scheme = if (useHttps) "wss" else "ws"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
return "$scheme://$address$portSuffix"
}
// name is the trailing field so entries saved before naming existed
// (4 fields) still deserialize, with name defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
companion object {
fun deserialize(raw: String): ServerEntry? {
val parts = raw.split("|")
if (parts.size < 2) return null
return ServerEntry(
address = parts[0],
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
)
}
}
}
class ServerPreferences(private val context: Context) {
private val activeAddressKey = stringPreferencesKey("active_address")
private val activeHttpsKey = booleanPreferencesKey("active_https")
private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
val address = prefs[activeAddressKey] ?: return@map null
ServerEntry(
address = address,
useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
)
}
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
val raw = prefs[savedServersKey] ?: emptySet()
raw.mapNotNull { ServerEntry.deserialize(it) }
}
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[introSeenKey] ?: false
}
suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
prefs[activeAddressKey] = server.address
prefs[activeHttpsKey] = server.useHttps
prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
}
addSavedServer(server)
}
suspend fun clearActiveServer() {
context.dataStore.edit { prefs ->
prefs.remove(activeAddressKey)
prefs.remove(activeHttpsKey)
prefs.remove(activePortKey)
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
}
}
suspend fun addSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
prefs[savedServersKey] = current + server.serialize()
}
}
/**
* Replace a saved server in place. Matches the existing entry by connection
* identity (address/port/scheme) so edits that change the name or password —
* or that touch a legacy 4-field entry — still update the right record. If the
* edited server is also the active one, the active record is kept in sync.
*/
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == original.address &&
e.port == original.port &&
e.useHttps == original.useHttps
}.toSet()
prefs[savedServersKey] = filtered + updated.serialize()
val isActive = prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
if (isActive) {
prefs[activeAddressKey] = updated.address
prefs[activeHttpsKey] = updated.useHttps
prefs[activePortKey] = updated.port
prefs[activePasswordKey] = updated.password
prefs[activeNameKey] = updated.name
}
}
}
/**
* Add a server, or update the entry with the same connection identity
* (address/port/scheme) — used by QR pairing so re-scanning a node never
* duplicates it. A blank incoming password/name keeps the stored value
* (a real node's QR never carries the password). Returns the merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
it.address == server.address &&
it.port == server.port &&
it.useHttps == server.useHttps
}
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
)
}
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
return merged
}
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
// Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
}.toSet()
}
}
suspend fun markIntroSeen() {
context.dataStore.edit { prefs ->
prefs[introSeenKey] = true
}
}
}
@@ -0,0 +1,67 @@
package com.archipelago.app.data
import android.net.Uri
/**
* Result of parsing a pairing QR / deep link.
*
* UnsupportedVersion means the payload is structurally a pairing URI but its
* major version is newer than this app understands — the UI should tell the
* user to update the app rather than call the code invalid.
*/
sealed class PairResult {
data class Success(val server: ServerEntry) : PairResult()
object UnsupportedVersion : PairResult()
object Invalid : PairResult()
}
/**
* Parser for the companion pairing QR / OS deep link. Contract:
* docs/companion-pairing-qr.md (repo root).
*
* archipelago://pair?v=1&url=<percent-encoded origin>[&pw=<password>]
*
* - `url` is a full origin including scheme (http for LAN/mDNS nodes, https
* for the public demo); trailing slashes are normalized away.
* - `pw` is only ever present for the public demo.
* - Unknown extra query params are tolerated (forward compat under v=1 —
* `name` is already honored if present).
*/
object ServerQrParser {
private const val SUPPORTED_MAJOR = 1
fun parse(raw: String): PairResult {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return PairResult.Invalid
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return PairResult.Invalid
if (uri.isOpaque || !"pair".equals(uri.host, ignoreCase = true)) return PairResult.Invalid
val major = uri.getQueryParameter("v")
?.trim()
?.takeWhile { it.isDigit() }
?.toIntOrNull()
?: return PairResult.Invalid
if (major != SUPPORTED_MAJOR) return PairResult.UnsupportedVersion
val serverUrl = uri.getQueryParameter("url")?.trim()?.trimEnd('/')
if (serverUrl.isNullOrBlank()) return PairResult.Invalid
val server = Uri.parse(serverUrl)
val scheme = server.scheme?.lowercase()
if (scheme != "http" && scheme != "https") return PairResult.Invalid
val host = server.host
if (host.isNullOrBlank()) return PairResult.Invalid
return PairResult.Success(
ServerEntry(
address = host,
useHttps = scheme == "https",
port = if (server.port != -1) server.port.toString() else "",
password = uri.getQueryParameter("pw") ?: "",
name = uri.getQueryParameter("name") ?: "",
)
)
}
}
@@ -0,0 +1,203 @@
package com.archipelago.app.network
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
enum class ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, AUTH_FAILED, ERROR }
class InputWebSocket(
private val scope: CoroutineScope,
) {
private var ws: WebSocket? = null
private var reconnectJob: Job? = null
private var reconnectAttempt = 0
private var serverUrl: String = ""
private var password: String = ""
private var sessionCookie: String? = null
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
var playerId: Int = 0
/**
* Invoked when the kiosk asks us to open a URL in the phone's default
* browser ({"t":"o","url":"…"}). "Open in external browser" apps can't be
* usefully opened on the kiosk, so the kiosk forwards them here.
*/
var onExternalOpen: ((String) -> Unit)? = null
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
val state: StateFlow<ConnectionState> = _state
private val trustManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
private val client: OkHttpClient by lazy {
val sc = SSLContext.getInstance("TLS")
sc.init(null, arrayOf(trustManager), java.security.SecureRandom())
OkHttpClient.Builder()
.sslSocketFactory(sc.socketFactory, trustManager)
.hostnameVerifier { _, _ -> true }
.pingInterval(30, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.build()
}
fun connect(httpUrl: String, pwd: String = "") {
disconnect()
serverUrl = httpUrl
password = pwd
sessionCookie = null
reconnectAttempt = 0
scope.launch(Dispatchers.IO) { doAuth() }
}
private suspend fun doAuth() {
_state.value = ConnectionState.CONNECTING
if (password.isBlank()) {
doConnect()
return
}
try {
val body = """{"method":"auth.login","params":{"password":"$password"}}"""
.toRequestBody("application/json".toMediaType())
val req = Request.Builder()
.url("$serverUrl/rpc/v1")
.post(body)
.build()
val response = withContext(Dispatchers.IO) { client.newCall(req).execute() }
if (response.isSuccessful) {
sessionCookie = response.headers("Set-Cookie")
.mapNotNull { cookie ->
cookie.split(";")
.firstOrNull()
?.trim()
?.takeIf { it.startsWith("session=") }
?.removePrefix("session=")
}
.firstOrNull()
response.close()
if (sessionCookie != null) {
doConnect()
} else {
_state.value = ConnectionState.AUTH_FAILED
}
} else {
response.close()
_state.value = ConnectionState.AUTH_FAILED
}
} catch (_: Exception) {
_state.value = ConnectionState.ERROR
scheduleReconnect()
}
}
private fun doConnect() {
val basePath = "/ws/remote-input" + if (playerId > 0) "?p=$playerId" else ""
val wsUrl = serverUrl
.replace("https://", "wss://")
.replace("http://", "ws://")
.trimEnd('/') + basePath
val reqBuilder = Request.Builder().url(wsUrl)
sessionCookie?.let { reqBuilder.header("Cookie", "session=$it") }
ws = client.newWebSocket(reqBuilder.build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_state.value = ConnectionState.CONNECTED
reconnectAttempt = 0
}
override fun onMessage(webSocket: WebSocket, text: String) {
// The only inbound message we act on is an external-open request
// forwarded from the kiosk: {"t":"o","url":"https://…"}.
try {
val obj = org.json.JSONObject(text)
if (obj.optString("t") == "o") {
val url = obj.optString("url")
if (url.startsWith("http://") || url.startsWith("https://")) {
onExternalOpen?.invoke(url)
}
}
} catch (_: Exception) {}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_state.value = ConnectionState.ERROR
scheduleReconnect()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocket.close(1000, null)
_state.value = ConnectionState.DISCONNECTED
if (code != 1000) scheduleReconnect()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
_state.value = ConnectionState.DISCONNECTED
}
})
}
private fun scheduleReconnect() {
reconnectJob?.cancel()
reconnectJob = scope.launch(Dispatchers.IO) {
val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempt, 5)), 30_000L)
reconnectAttempt++
delay(delayMs)
doAuth()
}
}
fun disconnect() {
reconnectJob?.cancel()
ws?.close(1000, "bye")
ws = null
_state.value = ConnectionState.DISCONNECTED
}
// ─── Input senders ──────────────────────────────────────────
fun sendKey(key: String) {
val pField = if (playerId > 0) ""","p":$playerId""" else ""
ws?.send("""{"t":"k","k":"$key"$pField}""")
}
fun sendMouseMove(dx: Int, dy: Int) {
ws?.send("""{"t":"m","x":$dx,"y":$dy}""")
}
fun sendClick(button: Int = 1) {
ws?.send("""{"t":"c","b":$button}""")
}
fun sendScroll(dy: Int) {
ws?.send("""{"t":"s","y":$dy}""")
}
}
@@ -0,0 +1,56 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
private val R = 14.dp
@Composable
fun ActionButtons(
onEscape: () -> Unit,
onEnter: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
NeoBtn("ESC", Neo.textSecondary(), Modifier.fillMaxWidth().weight(1f), onEscape)
NeoBtn("ENTER", BitcoinOrange.copy(alpha = 0.7f), Modifier.fillMaxWidth().weight(1f), onEnter)
}
}
@Composable
private fun NeoBtn(label: String, color: androidx.compose.ui.graphics.Color, modifier: Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
Box(
modifier = modifier
.then(if (p) Modifier.neoInset(l, d, R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, R, 2.dp, 4.dp))
.clip(RoundedCornerShape(R))
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = if (p) color else color.copy(alpha = 0.7f), fontSize = 12.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.5.sp)
}
}
@@ -0,0 +1,121 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val BTN = 50.dp
private val BTN_R = 12.dp
private val GAP = 8.dp
private val NOB = 24.dp
@Composable
fun DPad(
onDirection: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val surface = Neo.surface()
val raised = Neo.surfaceRaised()
val l = Neo.shadowLight()
val d = Neo.shadowDark()
// Recessed well
Box(
modifier = modifier
.neoInset(l, d, 20.dp, 2.dp, 4.dp)
.clip(RoundedCornerShape(20.dp))
.background(surface)
.padding(14.dp),
contentAlignment = Alignment.Center,
) {
// Cross layout with explicit spacing
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Btn(Icons.Default.KeyboardArrowUp, "Up", onDirection)
Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer
Row(verticalAlignment = Alignment.CenterVertically) {
Btn(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "Left", onDirection)
Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer
// Center nob
Box(
modifier = Modifier
.size(NOB)
.neoRaised(l, d, NOB / 2, 1.dp, 2.dp)
.clip(CircleShape)
.background(raised),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(8.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f)))
}
Box(modifier = Modifier.size(width = GAP, height = BTN)) // spacer
Btn(Icons.AutoMirrored.Filled.KeyboardArrowRight, "Right", onDirection)
}
Box(modifier = Modifier.size(height = GAP, width = BTN)) // spacer
Btn(Icons.Default.KeyboardArrowDown, "Down", onDirection)
}
}
}
@Composable
private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
var p by remember { mutableStateOf(false) }
val bg = Neo.surfaceRaised()
val l = Neo.shadowLight()
val d = Neo.shadowDark()
val tint = Neo.textPrimary()
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
modifier = Modifier
.size(BTN)
.then(if (p) Modifier.neoInset(l, d, BTN_R, 1.dp, 2.dp) else Modifier.neoRaised(l, d, BTN_R, 2.dp, 4.dp))
.clip(RoundedCornerShape(BTN_R))
.background(bg)
.pointerInput(key) {
detectTapGestures(onPress = {
p = true; onDir(key)
// 500ms initial delay so a normal tap sends one key, not two
// (a touch tap often exceeds 350ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
tryAwaitRelease(); p = false; job?.cancel()
})
},
contentAlignment = Alignment.Center,
) {
Icon(icon, key, Modifier.fillMaxSize(0.48f), tint = if (p) tint.copy(alpha = 0.9f) else tint.copy(alpha = 0.5f))
}
}
@@ -0,0 +1,134 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
@Composable
fun GamepadLayout(
onKey: (String) -> Unit,
onTwoFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
val surface = Neo.surface()
Box(
modifier = modifier
.fillMaxSize()
.background(surface)
.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onTwoFingerHold() }
if (a.size < 2) t = 0L
} while (ev.changes.any { it.pressed })
}
}
.padding(horizontal = 24.dp, vertical = 16.dp),
) {
// D-pad — centered left
DPad(
onDirection = onKey,
modifier = Modifier.align(Alignment.CenterStart).size(200.dp),
)
// Face buttons — centered right (diamond)
Column(
modifier = Modifier.align(Alignment.CenterEnd),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
FaceBtn("esc", 64.dp) { onKey("Escape") }
Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) {
FaceBtn("tab", 64.dp) { onKey("Tab") }
FaceBtn("enter", 64.dp, accent = true) { onKey("Return") }
}
FaceBtn("bksp", 64.dp) { onKey("BackSpace") }
}
// Bottom: L, SELECT, START, R
Row(
modifier = Modifier.align(Alignment.BottomCenter),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PillBtn("L", 56.dp) { onKey("Prior") }
PillBtn("SELECT", 80.dp) { onKey("Escape") }
PillBtn("START", 80.dp) { onKey("Return") }
PillBtn("R", 56.dp) { onKey("Next") }
}
}
}
@Composable
private fun FaceBtn(label: String, size: Dp, accent: Boolean = false, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
val tc = if (accent) BitcoinOrange.copy(alpha = 0.7f) else Neo.textSecondary()
Box(
modifier = Modifier
.size(size)
.then(if (p) Modifier.neoInset(l, d, size / 2, 1.dp, 3.dp) else Modifier.neoRaised(l, d, size / 2, 2.dp, 4.dp))
.clip(CircleShape)
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = if (p) tc.copy(alpha = 1f) else tc, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp)
}
}
@Composable
private fun PillBtn(label: String, w: Dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
Box(
modifier = Modifier
.width(w).height(34.dp)
.then(if (p) Modifier.neoInset(l, d, 8.dp, 1.dp, 2.dp) else Modifier.neoRaised(l, d, 8.dp, 2.dp, 4.dp))
.clip(RoundedCornerShape(8.dp))
.background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = Neo.textMuted(), fontSize = 9.sp, fontWeight = FontWeight.Medium, letterSpacing = 1.sp)
}
}
@@ -0,0 +1,468 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
// ═══════════════════════════════════════════════════════════
// Palettes
// ═══════════════════════════════════════════════════════════
data class NESPalette(
val body: Color, val face: Color, val ridge: Color,
val label: Color, val labelMuted: Color,
val dpad: Color, val dpadHi: Color,
val btn: Color, val btnPress: Color,
val capsule: Color, val capsulePress: Color,
val inlayBg: Color, val inlayBorder: Color,
)
val ClassicPalette = NESPalette(
body = NES.ClassicBody, face = NES.ClassicFace, ridge = NES.ClassicRidge,
label = NES.ClassicLabel, labelMuted = NES.ClassicLabelMuted,
dpad = Color(0xFF0C0C0C), dpadHi = Color(0xFF1A1A1A),
btn = NES.ClassicButtonRed, btnPress = NES.ClassicButtonRedPress,
capsule = Color(0xFF1C1C1C), capsulePress = Color(0xFF0E0E0E),
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
)
// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop
// shows through the controller, subtle white-alpha borders, translucent-white
// buttons. Accents come from each button's ring.
val DarkPalette = NESPalette(
body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF),
label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF),
)
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
// ═══════════════════════════════════════════════════════════
// Landscape NES Controller
// ═══════════════════════════════════════════════════════════
@Composable
fun NESController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
modifier = modifier
.fillMaxSize()
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Controller body
Box(
Modifier
.fillMaxWidth(0.86f)
.aspectRatio(2.3f)
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
.clip(RoundedCornerShape(16.dp))
.background(
Brush.verticalGradient(listOf(c.body, c.body))
)
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
) {
// Top highlight edge
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Box(
Modifier
.fillMaxSize()
.padding(14.dp)
.clip(RoundedCornerShape(10.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(10.dp)),
) {
// Ridges
Ridges(c.ridge, Modifier.align(Alignment.CenterStart).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
Ridges(c.ridge, Modifier.align(Alignment.CenterEnd).width(7.dp).fillMaxHeight().padding(vertical = 12.dp))
// D-Pad in inlay (more left margin)
Inlay(c, Modifier.align(Alignment.CenterStart).padding(start = 48.dp).size(140.dp)) {
OnePointDPad(c, 120.dp, onKey)
}
// Center: Logo + START/SELECT
Column(
Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(180.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(10.dp))
Inlay(c, Modifier.padding(horizontal = 4.dp)) {
Row(
Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
}
// A/B/C Buttons in inlay — triangle: C top, B+A bottom
Inlay(c, Modifier.align(Alignment.CenterEnd).padding(end = 48.dp).size(140.dp)) {
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// C on top
GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
// B + A on bottom row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
}
}
}
// Player toggle + settings (bottom center)
Row(
Modifier.align(Alignment.BottomCenter).padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
SettingsBtn(c, Modifier, onMenu)
}
}
}
}
}
// ═══════════════════════════════════════════════════════════
// Shared sub-components
// ═══════════════════════════════════════════════════════════
/** Inlay well — dark recessed area with border */
@Composable
fun Inlay(c: NESPalette, modifier: Modifier = Modifier, content: @Composable () -> Unit) {
Box(
modifier = modifier
.clip(RoundedCornerShape(10.dp))
.background(c.inlayBg)
.border(3.dp, c.inlayBorder, RoundedCornerShape(10.dp))
.padding(4.dp),
contentAlignment = Alignment.Center,
) { content() }
}
/** One-piece D-pad — single cross shape, touch detects direction */
@Composable
fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
var activeDir by remember { mutableStateOf<String?>(null) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Canvas(
modifier = Modifier
.size(size)
.pointerInput(Unit) {
detectTapGestures(
onPress = { offset ->
val cx = this@pointerInput.size.width / 2f
val cy = this@pointerInput.size.height / 2f
val dx = offset.x - cx
val dy = offset.y - cy
val dead = cx * 0.24f
if (abs(dx) < dead && abs(dy) < dead) {
tryAwaitRelease(); return@detectTapGestures
}
val dir = if (abs(dx) > abs(dy)) {
if (dx > 0) "Right" else "Left"
} else {
if (dy > 0) "Down" else "Up"
}
activeDir = dir; onDir(dir)
job?.cancel()
// 500ms initial delay so a normal tap sends one key, not
// two (a touch tap often exceeds 300ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
tryAwaitRelease()
job?.cancel(); activeDir = null
},
)
},
) {
val w = size.toPx()
val arm = w * 0.33f // arm width = 1/3 of total
val offset = (w - arm) / 2f
// Cross shape
val crossColor = c.dpad
// Vertical bar
drawRoundRect(
color = crossColor,
topLeft = Offset(offset, 0f),
size = Size(arm, w),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Horizontal bar
drawRoundRect(
color = crossColor,
topLeft = Offset(0f, offset),
size = Size(w, arm),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Top-edge lighting
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(offset, 0f),
size = Size(arm, w * 0.15f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
drawRoundRect(
brush = Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
topLeft = Offset(0f, offset),
size = Size(w, arm * 0.3f),
cornerRadius = CornerRadius(4.dp.toPx()),
)
// Active direction highlight
activeDir?.let { dir ->
val hi = c.dpadHi
when (dir) {
"Up" -> drawRoundRect(hi, Offset(offset, 0f), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Down" -> drawRoundRect(hi, Offset(offset, w - arm), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Left" -> drawRoundRect(hi, Offset(0f, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
"Right" -> drawRoundRect(hi, Offset(w - arm, offset), Size(arm, arm), CornerRadius(4.dp.toPx()))
}
}
// Center circle
drawCircle(c.dpadHi, radius = w * 0.06f, center = Offset(w / 2f, w / 2f))
}
}
@Composable
fun Ridges(color: Color, modifier: Modifier) {
Canvas(modifier = modifier) {
val h = 1.5.dp.toPx(); val gap = 3.dp.toPx(); var y = 0f
while (y < size.height) { drawRect(color, Offset(0f, y), Size(size.width, h)); y += h + gap }
}
}
/** A/B round button with lighting */
@Composable
fun RoundBtn(c: NESPalette, sz: Dp = 52.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(Brush.verticalGradient(
if (p) listOf(c.btnPress, c.btn.copy(alpha = 0.85f))
else listOf(c.btn, c.btn.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** Colored round button — custom color instead of palette */
@Composable
fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.shadow(if (p) 1.dp else 4.dp, CircleShape)
.clip(CircleShape)
.background(Brush.verticalGradient(
if (p) listOf(pressColor, color.copy(alpha = 0.85f))
else listOf(color, color.copy(alpha = 0.8f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(CircleShape).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.18f), Color.Transparent))
))
}
}
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
@Composable
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.clip(CircleShape)
.background(
Brush.verticalGradient(
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
)
)
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
/** START/SELECT capsule */
@Composable
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.width(w).height(h)
.shadow(if (p) 0.dp else 2.dp, RoundedCornerShape(4.dp))
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(
if (p) listOf(c.capsulePress, c.capsule)
else listOf(c.capsule, c.capsule.copy(alpha = 0.85f))
))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
if (!p) Box(Modifier.fillMaxSize().clip(RoundedCornerShape(4.dp)).background(
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.05f), Color.Transparent))
))
Text(label, color = c.labelMuted, fontSize = 8.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
/** Settings gear button (48dp — large enough for easy tap on TV) */
@Composable
fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.size(48.dp)
.clip(CircleShape)
.background(if (p) c.capsulePress else c.capsule)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Settings, "Settings", Modifier.size(28.dp), tint = c.labelMuted)
}
}
/** Player ID toggle pill (P1/P2/ALL) */
@Composable
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
val label = when (playerId) { 1 -> "P1"; 2 -> "P2"; else -> "ALL" }
val accent = when (playerId) { 1 -> Color(0xFF00F0FF); 2 -> Color(0xFFFF0080); else -> c.labelMuted }
var p by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.height(28.dp)
.width(44.dp)
.clip(RoundedCornerShape(6.dp))
.background(if (p) c.capsulePress else c.capsule)
.border(1.dp, accent.copy(alpha = 0.5f), RoundedCornerShape(6.dp))
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onToggle(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent, fontSize = 10.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.sp)
}
}
/** Two-finger hold gesture modifier */
fun Modifier.twoFingerHold(onHold: () -> Unit) = this.pointerInput(Unit) {
awaitEachGesture {
awaitFirstDown(requireUnconsumed = false)
var t = 0L; var fired = false
do {
val ev = awaitPointerEvent()
val a = ev.changes.filter { !it.changedToUp() }
if (a.size >= 2 && t == 0L) t = System.currentTimeMillis()
if (a.size >= 2 && !fired && t > 0 && System.currentTimeMillis() - t > 500) { fired = true; onHold() }
if (a.size < 2) t = 0L
} while (ev.changes.any { it.pressed })
}
}
@@ -0,0 +1,211 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private enum class NKLayer { ALPHA, NUM, SYM }
private val KEY_H = 42.dp
private val GAP = 4.dp
@Composable
fun NESKeyboard(
style: ControllerStyle = ControllerStyle.CLASSIC,
onKey: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
val keyBg = c.dpad
val keyBgP = c.dpadHi
val keyTxt = c.labelMuted
val accent = if (isClassic) NES.ClassicLabel else c.labelMuted
var layer by remember { mutableStateOf(NKLayer.ALPHA) }
var shifted by remember { mutableStateOf(false) }
var capsLock by remember { mutableStateOf(false) }
var ctrlHeld by remember { mutableStateOf(false) }
val up = shifted || capsLock
fun emit(k: String) {
val key = if (ctrlHeld) "ctrl+$k" else k
onKey(key)
if (shifted && !capsLock) shifted = false
if (ctrlHeld) ctrlHeld = false
}
fun ch(cc: String) { emit(if (up && layer == NKLayer.ALPHA) "shift+$cc" else cc) }
// NES body wrapping keyboard
Column(
modifier = modifier
.clip(RoundedCornerShape(14.dp))
.background(c.body)
.padding(8.dp)
.clip(RoundedCornerShape(8.dp))
.background(c.face)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(GAP),
) {
when (layer) {
NKLayer.ALPHA -> {
KeyRow("q w e r t y u i o p".split(" "), up, keyBg, keyBgP, keyTxt, ::ch)
KeyRow("a s d f g h j k l".split(" "), up, keyBg, keyBgP, keyTxt, ::ch, inset = 16.dp)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), keyBg, keyBgP, if (up) accent else keyTxt) {
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
}
"z x c v b n m".split(" ").forEach { k ->
NKey(if (up) k.uppercase() else k, Modifier.weight(1f), keyBg, keyBgP, keyTxt, 17) { ch(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
NKLayer.NUM -> {
KeyRow("1 2 3 4 5 6 7 8 9 0".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
KeyRow("- / : ; ( ) \$ & @ \"".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey("#+=", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.SYM }
". , ? ! '".split(" ").forEach { k ->
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
NKLayer.SYM -> {
KeyRow("[ ] { } # % ^ * + =".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
KeyRow("_ \\ | ~ < > ` @ !".split(" "), false, keyBg, keyBgP, keyTxt, ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey("123", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { layer = NKLayer.NUM }
". , ? ! '".split(" ").forEach { k ->
NKey(k, Modifier.weight(1f), keyBg, keyBgP, keyTxt) { emit(k) }
}
NRepKey("\u232B", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) { emit("BackSpace") }
}
}
}
// Bottom row
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
NKey(if (layer == NKLayer.ALPHA) "123" else "ABC", Modifier.weight(1.4f), keyBg, keyBgP, keyTxt) {
layer = if (layer == NKLayer.ALPHA) NKLayer.NUM else NKLayer.ALPHA; shifted = false; capsLock = false
}
NKey("Ctrl", Modifier.weight(1.2f), keyBg, keyBgP, if (ctrlHeld) accent else keyTxt, 11) {
ctrlHeld = !ctrlHeld
}
NKey(",", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("comma") }
NKey("space", Modifier.weight(4f), keyBg, keyBgP, keyTxt, 12) { emit("space") }
NKey(".", Modifier.weight(0.8f), keyBg, keyBgP, keyTxt) { emit("period") }
NKey("\u23CE", Modifier.weight(1.4f), keyBg, keyBgP, accent, 15) { emit("Return") }
}
}
}
/** Key row — each key gets equal weight */
@Composable
private fun KeyRow(
keys: List<String>, up: Boolean,
bg: Color, bgP: Color, txt: Color,
onKey: (String) -> Unit, inset: Dp = 0.dp,
) {
Row(
Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset),
Arrangement.spacedBy(GAP),
) {
keys.forEach { k ->
NKey(
label = if (up) k.uppercase() else k,
modifier = Modifier.weight(1f),
bg = bg, bgP = bgP, txt = txt,
fontSize = 17,
onTap = { onKey(k) },
)
}
}
}
/** Single NES key — D-pad style flat dark button */
@Composable
private fun NKey(
label: String, modifier: Modifier = Modifier,
bg: Color, bgP: Color, txt: Color,
fontSize: Int = 13, onTap: () -> Unit,
) {
var p by remember { mutableStateOf(false) }
Box(
modifier = modifier
.height(KEY_H)
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
.then(
if (!p) Modifier.border(0.5.dp,
Brush.verticalGradient(listOf(Color.White.copy(alpha = 0.06f), Color.Transparent)),
RoundedCornerShape(4.dp))
else Modifier
)
.pointerInput(label) {
detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false })
},
contentAlignment = Alignment.Center,
) {
Text(label, color = txt, fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1)
}
}
/** Repeatable NES key (backspace) */
@Composable
private fun NRepKey(
label: String, modifier: Modifier,
bg: Color, bgP: Color, txt: Color, onTap: () -> Unit,
) {
var p by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var job by remember { mutableStateOf<Job?>(null) }
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
modifier = modifier
.height(KEY_H)
.clip(RoundedCornerShape(4.dp))
.background(Brush.verticalGradient(if (p) listOf(bgP, bg) else listOf(bg, bg.copy(alpha = 0.9f))))
.pointerInput(Unit) {
detectTapGestures(onPress = {
p = true; onTap()
job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
tryAwaitRelease(); job?.cancel(); p = false
})
},
contentAlignment = Alignment.Center,
) {
Text(label, color = txt, fontSize = 16.sp)
}
}
@@ -0,0 +1,373 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
// Bitcoin-orange accent.
private val PanelBg = SurfaceDark // #0A0A0A
private val PanelBorder = Color.White.copy(alpha = 0.12f)
private val RowBg = Color.White.copy(alpha = 0.05f)
private val RowBorder = Color.White.copy(alpha = 0.08f)
private val FieldBg = Color.White.copy(alpha = 0.04f)
private val PANEL_R = 20.dp
private val ROW_R = 14.dp
private val ROW_H = 54.dp
private val FIELD_H = 58.dp
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
@Composable
fun NESMenu(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)? = null,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
}
}
}
}
@Composable
private fun MenuPanel(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
controllerStyle: ControllerStyle,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onScanQr: (() -> Unit)?,
onEditServer: (ServerEntry, ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onBackToWebView: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
var editing by remember { mutableStateOf<ServerEntry?>(null) }
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
fun resetForm() {
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
}
fun startEdit(server: ServerEntry) {
editing = server
nm = server.name; addr = server.address; pwd = server.password
showAdd = false
}
fun submit() {
if (addr.isBlank()) return
val orig = editing
if (orig != null) {
// Preserve fields the compact form doesn't expose (scheme, port).
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
} else {
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
}
resetForm()
}
Column(
modifier = Modifier
.widthIn(max = 420.dp)
.padding(horizontal = 20.dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg)
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Title
Text(
"Menu",
color = TextPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
// Servers
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onEdit = { startEdit(server) },
onRemove = { onRemoveServer(server) },
)
}
if (servers.isEmpty()) {
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
// Add / edit server
if (showAdd || editing != null) {
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
if (editing != null) "Edit Server" else "Add Server",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
fontWeight = FontWeight.Medium,
)
Text(
"Cancel",
color = TextMuted,
fontSize = 13.sp,
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
)
}
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.weight(1f)) {
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
if (onScanQr != null) {
// Add server by scanning the node's pairing QR
Box(
Modifier
.size(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(RowBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.clickable { onScanQr() },
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.QrCodeScanner,
contentDescription = stringResource(R.string.add_server_qr),
tint = BitcoinOrange,
modifier = Modifier.size(24.dp),
)
}
}
}
}
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp))
// Mode toggle
MenuItem(
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode,
)
// Style toggle
MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
)
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
}
}
}
@Composable
private fun MenuItem(
label: String,
selected: Boolean = false,
labelColor: Color = TextPrimary,
onClick: () -> Unit,
onEdit: (() -> Unit)? = null,
onRemove: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.height(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
if (onEdit != null) {
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onEdit() }.padding(horizontal = 8.dp),
)
}
if (onRemove != null) {
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
)
}
}
}
/** Glass text field with centered input text. */
@Composable
private fun GlassField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
modifier = modifier.fillMaxWidth().height(FIELD_H),
singleLine = true,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}
@@ -0,0 +1,158 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
/**
* Portrait gamepad — vertical remote shape like Apple TV but NES-styled.
* Large trackpad top, D-pad middle, A/B + START/SELECT bottom.
*/
@Composable
fun NESPortraitController(
style: ControllerStyle = ControllerStyle.CLASSIC,
playerId: Int = 0,
onKey: (String) -> Unit,
onMouseMove: (Int, Int) -> Unit = { _, _ -> },
onMouseClick: (Int) -> Unit = { _ -> },
onMouseScroll: (Int) -> Unit = { _ -> },
onMenu: () -> Unit,
onPlayerToggle: () -> Unit = {},
) {
val c = paletteFor(style)
val isClassic = style == ControllerStyle.CLASSIC
Box(
Modifier
.fillMaxSize()
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Remote body — tall vertical shape
Box(
Modifier
.fillMaxWidth(0.75f)
.fillMaxSize()
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
.clip(RoundedCornerShape(20.dp))
.background(Brush.verticalGradient(listOf(c.body, c.body)))
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
) {
// Top highlight
Box(
Modifier.fillMaxWidth().height(1.dp).align(Alignment.TopCenter)
.background(Color.White.copy(alpha = if (isClassic) 0.12f else 0.05f))
)
// Face plate
Column(
Modifier
.fillMaxSize()
.padding(14.dp)
.clip(RoundedCornerShape(14.dp))
.background(c.face)
.border(0.5.dp, Color.White.copy(alpha = 0.03f), RoundedCornerShape(14.dp))
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
// Trackpad area (touch surface for mouse)
Trackpad(
onMove = { dx, dy -> onMouseMove(dx, dy) },
onClick = { onMouseClick(it) },
onScroll = { dy -> onMouseScroll(dy) },
onTwoFingerHold = onMenu,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
)
Spacer(Modifier.height(12.dp))
// D-Pad
Inlay(c, Modifier.size(150.dp)) {
OnePointDPad(c, 130.dp, onKey)
}
Spacer(Modifier.height(12.dp))
// Logo
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
contentDescription = "Archipelago",
modifier = Modifier.width(140.dp),
colorFilter = ColorFilter.tint(if (isClassic) NES.ClassicLabel else c.label),
)
Spacer(Modifier.height(12.dp))
// A/B/C Buttons — triangle: C top, B+A bottom
Inlay(c, Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
}
}
}
Spacer(Modifier.height(10.dp))
// START / SELECT
Inlay(c, Modifier) {
Row(
Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
CapsuleBtn("SELECT", c, 64.dp, 28.dp) { onKey("Escape") }
CapsuleBtn("START", c, 64.dp, 28.dp) { onKey("Return") }
}
}
Spacer(Modifier.height(6.dp))
// Player toggle + Settings
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
PlayerPill(c, playerId, onPlayerToggle)
Spacer(Modifier.width(10.dp))
SettingsBtn(c, Modifier, onMenu)
}
}
}
}
}
@@ -0,0 +1,316 @@
package com.archipelago.app.ui.components
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.archipelago.app.R
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.GlassButton
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import kotlinx.coroutines.delay
import java.util.concurrent.Executors
/**
* Full-screen camera overlay that scans the node pairing QR
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
* Handles the camera permission itself; foreign/invalid codes show a hint
* and scanning continues.
*/
@Composable
fun QrScannerOverlay(
visible: Boolean,
onDismiss: () -> Unit,
onServerScanned: (ServerEntry) -> Unit,
) {
val context = LocalContext.current
var hasPermission by remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
)
}
var hintRes by remember { mutableStateOf<Int?>(null) }
var handled by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPermission = granted }
LaunchedEffect(visible) {
if (visible) {
handled = false
hintRes = null
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
hasPermission = granted
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
}
}
// Foreign-code hint fades after a moment so scanning feels live again.
LaunchedEffect(hintRes) {
if (hintRes != null) {
delay(2500)
hintRes = null
}
}
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
BackHandler { onDismiss() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black),
) {
if (hasPermission) {
CameraQrPreview(
onDecoded = { text ->
if (!handled) {
when (val result = ServerQrParser.parse(text)) {
is PairResult.Success -> {
handled = true
onServerScanned(result.server)
}
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
}
}
},
)
// Aim frame
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
} else {
Column(
Modifier
.align(Alignment.Center)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = stringResource(R.string.camera_permission_needed),
color = TextPrimary,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
GlassButton(
text = stringResource(R.string.grant_camera_access),
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
// Top bar: title + close
Row(
Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.scan_node_qr),
color = TextPrimary,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(start = 12.dp),
)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
}
}
// Bottom hints
Column(
Modifier
.align(Alignment.BottomCenter)
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
hintRes?.let { res ->
Text(
text = stringResource(res),
color = BitcoinOrange,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
}
if (hasPermission) {
Text(
text = stringResource(R.string.scan_qr_hint),
color = TextMuted,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
}
}
}
}
}
@Composable
private fun CameraQrPreview(onDecoded: (String) -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val currentOnDecoded by rememberUpdatedState(onDecoded)
val previewView = remember {
PreviewView(context).apply { scaleType = PreviewView.ScaleType.FILL_CENTER }
}
DisposableEffect(Unit) {
val analysisExecutor = Executors.newSingleThreadExecutor()
val mainExecutor = ContextCompat.getMainExecutor(context)
val providerFuture = ProcessCameraProvider.getInstance(context)
var provider: ProcessCameraProvider? = null
providerFuture.addListener({
val p = providerFuture.get()
provider = p
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
// CameraX's analysis default is 640x480 — too few pixels per module
// to decode a modal-sized QR at arm's length. 1280x720 more than
// doubles the pixel density at negligible analysis cost.
@Suppress("DEPRECATION")
val analysis = ImageAnalysis.Builder()
.setTargetResolution(android.util.Size(1280, 720))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(
analysisExecutor,
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
)
}
try {
p.unbindAll()
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
} catch (_: Exception) {
// Camera unavailable — the user can dismiss and enter details manually.
}
}, mainExecutor)
onDispose {
provider?.unbindAll()
analysisExecutor.shutdown()
}
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
}
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
private val reader = MultiFormatReader().apply {
setHints(
mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
// Screen-displayed QRs come with moiré, glare, and soft focus at
// close range — the exhaustive search is worth the milliseconds.
DecodeHintType.TRY_HARDER to true,
)
)
}
override fun analyze(image: ImageProxy) {
try {
val plane = image.planes[0]
val buffer = plane.buffer
// Copy into a rowStride-wide array; the last row of the plane buffer
// may be short of the full stride, so the tail stays zero-padded.
val data = ByteArray(plane.rowStride * image.height)
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
val source = PlanarYUVLuminanceSource(
data, plane.rowStride, image.height,
0, 0, image.width, image.height,
false,
)
val result = try {
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
} catch (_: NotFoundException) {
// Dark-themed pages can render light-on-dark QRs — retry inverted.
reader.reset()
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
}
onDecoded(result.text)
} catch (_: NotFoundException) {
// No QR in this frame — keep scanning.
} catch (_: Exception) {
// Malformed frame; skip it.
} finally {
reader.reset()
image.close()
}
}
}
@@ -0,0 +1,263 @@
package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Keyboard
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material.icons.filled.Web
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.neoRaised
private val ROW_H = 48.dp
private val ROW_R = 12.dp
@Composable
fun ServerModal(
visible: Boolean,
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleGamepadMode: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.55f))
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
) { onDismiss() },
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
ModalBody(servers, activeServer, isGamepadMode, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleGamepadMode, onBackToWebView)
}
}
}
}
@Composable
private fun ModalBody(
servers: List<ServerEntry>,
activeServer: ServerEntry?,
isGamepadMode: Boolean,
onDismiss: () -> Unit,
onSelectServer: (ServerEntry) -> Unit,
onAddServer: (ServerEntry) -> Unit,
onRemoveServer: (ServerEntry) -> Unit,
onToggleGamepadMode: () -> Unit,
onBackToWebView: (() -> Unit)?,
) {
val surface = Neo.surfaceRaised()
val light = Neo.shadowLight()
val dark = Neo.shadowDark()
var showAddForm by remember { mutableStateOf(false) }
var newAddress by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
Column(
modifier = Modifier
.widthIn(max = 380.dp)
.neoRaised(light, dark, 24.dp, 6.dp, 12.dp)
.clip(RoundedCornerShape(24.dp))
.background(surface)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Header
Row(Modifier.fillMaxWidth(), Arrangement.SpaceBetween, Alignment.CenterVertically) {
Text("Servers", style = MaterialTheme.typography.titleMedium, color = Neo.textPrimary())
IconButton(onClick = onDismiss, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, "Close", Modifier.size(16.dp), tint = Neo.textMuted())
}
}
// Server rows
servers.forEach { server ->
val isActive = server.serialize() == activeServer?.serialize()
ModalRow(
icon = if (isActive) Icons.Default.RadioButtonChecked else Icons.Default.RadioButtonUnchecked,
iconTint = if (isActive) BitcoinOrange else Neo.textMuted(),
label = server.address + if (server.port.isNotBlank()) ":${server.port}" else "",
onClick = { onSelectServer(server) },
trailing = {
IconButton(onClick = { onRemoveServer(server) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, "Remove", Modifier.size(14.dp), tint = Neo.textMuted())
}
},
)
}
if (servers.isEmpty()) {
Text("No servers", style = MaterialTheme.typography.bodyMedium, color = Neo.textMuted(), modifier = Modifier.padding(vertical = 4.dp))
}
// Add server
if (showAddForm) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(Neo.surface())
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = newAddress, onValueChange = { newAddress = it.trim() },
placeholder = { Text("192.168.1.100") },
modifier = Modifier.fillMaxWidth(), singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
colors = neoFieldColors(),
shape = RoundedCornerShape(10.dp),
textStyle = MaterialTheme.typography.bodyMedium,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = newPassword, onValueChange = { newPassword = it },
placeholder = { Text("Password") },
modifier = Modifier.weight(1f), singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = {
if (newAddress.isNotBlank()) {
onAddServer(ServerEntry(newAddress, false, password = newPassword))
newAddress = ""; newPassword = ""; showAddForm = false
}
}),
colors = neoFieldColors(),
shape = RoundedCornerShape(10.dp),
textStyle = MaterialTheme.typography.bodyMedium,
)
Box(
modifier = Modifier.size(36.dp).clip(CircleShape).background(BitcoinOrange.copy(alpha = 0.15f))
.clickable {
if (newAddress.isNotBlank()) {
onAddServer(ServerEntry(newAddress, false, password = newPassword))
newAddress = ""; newPassword = ""; showAddForm = false
}
},
contentAlignment = Alignment.Center,
) { Icon(Icons.Default.Add, "Add", Modifier.size(16.dp), tint = BitcoinOrange) }
}
}
} else {
ModalRow(icon = Icons.Default.Add, iconTint = BitcoinOrange, label = "Add Server", labelColor = BitcoinOrange, onClick = { showAddForm = true })
}
HorizontalDivider(color = Neo.border(), modifier = Modifier.padding(vertical = 4.dp))
// Gamepad toggle — label says what you switch TO
ModalRow(
icon = if (isGamepadMode) Icons.Default.Keyboard else Icons.Default.Gamepad,
iconTint = Neo.textSecondary(),
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleGamepadMode,
)
// Back to dashboard
if (onBackToWebView != null) {
ModalRow(icon = Icons.Default.Web, iconTint = Neo.textSecondary(), label = "Back to Dashboard", onClick = onBackToWebView)
}
}
}
/** Uniform-height row used for all modal actions */
@Composable
private fun ModalRow(
icon: ImageVector,
iconTint: Color,
label: String,
onClick: () -> Unit,
labelColor: Color = Neo.textPrimary(),
trailing: (@Composable () -> Unit)? = null,
) {
val bg = Neo.surface()
val light = Neo.shadowLight()
val dark = Neo.shadowDark()
Row(
modifier = Modifier
.fillMaxWidth()
.height(ROW_H)
.neoRaised(light, dark, ROW_R, 2.dp, 5.dp)
.clip(RoundedCornerShape(ROW_R))
.background(bg)
.clickable { onClick() }
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(icon, null, Modifier.size(18.dp), tint = iconTint)
Spacer(Modifier.width(12.dp))
Text(label, style = MaterialTheme.typography.bodyMedium, color = labelColor, modifier = Modifier.weight(1f))
if (trailing != null) trailing()
}
}
@Composable
private fun neoFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = BitcoinOrange.copy(alpha = 0.4f),
unfocusedBorderColor = Neo.border(),
cursorColor = BitcoinOrange,
focusedTextColor = Neo.textPrimary(),
unfocusedTextColor = Neo.textPrimary(),
)
@@ -0,0 +1,107 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
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.geometry.Offset
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.unit.dp
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
private const val TAP_THRESHOLD = 12f
private const val TAP_TIMEOUT = 250L
@Composable
fun Trackpad(
onMove: (dx: Int, dy: Int) -> Unit,
onClick: (button: Int) -> Unit,
onScroll: (dy: Int) -> Unit,
onTwoFingerHold: () -> Unit,
modifier: Modifier = Modifier,
) {
var fingers by remember { mutableIntStateOf(0) }
val surface = Neo.surface()
val light = Neo.shadowLight()
val dark = Neo.shadowDark()
val muted = Neo.textMuted()
Box(
modifier = modifier
.neoInset(light, dark, 20.dp, 3.dp, 6.dp)
.clip(RoundedCornerShape(20.dp))
.background(surface)
.pointerInput(Unit) {
awaitEachGesture {
val first = awaitFirstDown(requireUnconsumed = false)
var total = Offset.Zero
val t0 = System.currentTimeMillis()
var maxPtrs = 1
var holdFired = false
var twoStart = 0L
var scrollAcc = 0f
fingers = 1
do {
val ev = awaitPointerEvent()
val active = ev.changes.filter { !it.changedToUp() }
maxPtrs = maxOf(maxPtrs, active.size)
fingers = active.size
when {
active.size >= 2 -> {
if (twoStart == 0L) twoStart = System.currentTimeMillis()
if (!holdFired && System.currentTimeMillis() - twoStart > 500) {
holdFired = true
onTwoFingerHold()
}
if (!holdFired) {
val dy = active.map { it.positionChange().y }.average().toFloat()
scrollAcc += dy
if (kotlin.math.abs(scrollAcc) > 12f) {
onScroll(if (scrollAcc > 0) 1 else -1)
scrollAcc = 0f
}
}
ev.changes.forEach { it.consume() }
}
active.size == 1 && maxPtrs == 1 -> {
val d = active.first().positionChange()
total += d
if (d != Offset.Zero) onMove(d.x.toInt(), d.y.toInt())
active.first().consume()
}
}
} while (ev.changes.any { it.pressed })
fingers = 0
val elapsed = System.currentTimeMillis() - t0
if (maxPtrs == 1 && elapsed < TAP_TIMEOUT && total.getDistance() < TAP_THRESHOLD) {
onClick(1)
}
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = if (fingers >= 2) "hold for menu" else "",
style = MaterialTheme.typography.labelSmall,
color = muted.copy(alpha = 0.4f),
)
}
}
@@ -0,0 +1,173 @@
package com.archipelago.app.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.Neo
import com.archipelago.app.ui.theme.neoInset
import com.archipelago.app.ui.theme.neoRaised
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private enum class Layer { ALPHA, NUM, SYM }
private val KEY_H = 46.dp
private val KEY_R = 10.dp
private val GAP = 5.dp
@Composable
fun VirtualKeyboard(onKey: (String) -> Unit, modifier: Modifier = Modifier) {
var layer by remember { mutableStateOf(Layer.ALPHA) }
var shifted by remember { mutableStateOf(false) }
var capsLock by remember { mutableStateOf(false) }
val up = shifted || capsLock
fun emit(k: String) { onKey(k); if (shifted && !capsLock) shifted = false }
fun ch(c: String) { emit(if (up && layer == Layer.ALPHA) "shift+$c" else c) }
Column(
modifier = modifier.background(Neo.surface()).padding(horizontal = 6.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(GAP),
) {
when (layer) {
Layer.ALPHA -> {
CRow("q w e r t y u i o p".split(" "), up, ::ch)
CRow("a s d f g h j k l".split(" "), up, ::ch, inset = 18.dp)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey(if (capsLock) "\u21EA" else "\u21E7", Modifier.weight(1.4f), active = up) {
if (capsLock) { capsLock = false; shifted = false } else if (shifted) capsLock = true else shifted = true
}
"z x c v b n m".split(" ").forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { ch(c) } }
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
}
}
Layer.NUM -> {
SRow("1 2 3 4 5 6 7 8 9 0".split(" "), ::emit)
SRow("- / : ; ( ) \$ & @ \"".split(" "), ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey("#+=", Modifier.weight(1.4f)) { layer = Layer.SYM }
". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } }
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
}
}
Layer.SYM -> {
SRow("[ ] { } # % ^ * + =".split(" "), ::emit)
SRow("_ \\ | ~ < > ` @ !".split(" "), ::emit)
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey("123", Modifier.weight(1.4f)) { layer = Layer.NUM }
". , ? ! '".split(" ").forEach { c -> CKey(c, Modifier.weight(1f)) { emit(c) } }
RKey("\u232B", Modifier.weight(1.4f)) { emit("BackSpace") }
}
}
}
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
SKey(if (layer == Layer.ALPHA) "123" else "ABC", Modifier.weight(1.4f)) {
layer = if (layer == Layer.ALPHA) Layer.NUM else Layer.ALPHA; shifted = false; capsLock = false
}
CKey(",", Modifier.weight(1f)) { emit("comma") }
CKey("space", Modifier.weight(5f), fontSize = 13) { emit("space") }
CKey(".", Modifier.weight(1f)) { emit("period") }
AKey("\u23CE", Modifier.weight(1.4f)) { emit("Return") }
}
}
}
@Composable
private fun CRow(keys: List<String>, up: Boolean, onKey: (String) -> Unit, inset: Dp = 0.dp) {
Row(Modifier.fillMaxWidth().height(KEY_H).padding(horizontal = inset), Arrangement.spacedBy(GAP)) {
keys.forEach { c -> CKey(if (up) c.uppercase() else c, Modifier.weight(1f)) { onKey(c) } }
}
}
@Composable
private fun SRow(keys: List<String>, onKey: (String) -> Unit) {
Row(Modifier.fillMaxWidth().height(KEY_H), Arrangement.spacedBy(GAP)) {
keys.forEach { c -> CKey(c, Modifier.weight(1f)) { onKey(c) } }
}
}
/** Character key */
@Composable
private fun CKey(label: String, modifier: Modifier = Modifier, fontSize: Int = 19, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark(); val t = Neo.textPrimary()
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(bg)
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) { Text(label, color = t.copy(alpha = if (p) 0.9f else 0.7f), fontSize = fontSize.sp, textAlign = TextAlign.Center, maxLines = 1) }
}
/** Special key */
@Composable
private fun SKey(label: String, modifier: Modifier = Modifier, active: Boolean = false, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val bg = Neo.surfaceRaised(); val l = Neo.shadowLight(); val d = Neo.shadowDark()
val tc = if (active) BitcoinOrange.copy(alpha = 0.8f) else Neo.textSecondary()
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(bg)
.pointerInput(label) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) { Text(label, color = tc, fontSize = 14.sp, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center) }
}
/** Accent key (return) */
@Composable
private fun AKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onTap(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) { Text(label, color = BitcoinOrange.copy(alpha = 0.7f), fontSize = 17.sp, fontWeight = FontWeight.Bold) }
}
/** Repeatable key (backspace) */
@Composable
private fun RKey(label: String, modifier: Modifier = Modifier, onTap: () -> Unit) {
var p by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope(); var job by remember { mutableStateOf<Job?>(null) }
val l = Neo.shadowLight(); val d = Neo.shadowDark()
DisposableEffect(Unit) { onDispose { job?.cancel() } }
Box(
modifier = modifier.height(KEY_H)
.then(if (p) Modifier.neoInset(l, d, KEY_R) else Modifier.neoRaised(l, d, KEY_R))
.clip(RoundedCornerShape(KEY_R)).background(Neo.surfaceRaised())
.pointerInput(Unit) { detectTapGestures(onPress = {
p = true; onTap(); job = scope.launch { delay(400); while (true) { onTap(); delay(55) } }
tryAwaitRelease(); job?.cancel(); p = false
}) },
contentAlignment = Alignment.Center,
) { Text(label, color = Neo.textSecondary(), fontSize = 17.sp) }
}
@@ -0,0 +1,151 @@
package com.archipelago.app.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.archipelago.app.data.PairResult
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.ui.screens.IntroScreen
import com.archipelago.app.ui.screens.RemoteInputScreen
import com.archipelago.app.ui.screens.ServerConnectScreen
import com.archipelago.app.ui.screens.WebViewScreen
import kotlinx.coroutines.launch
object Routes {
const val INTRO = "intro"
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
}
@Composable
fun AppNavHost(
pairUri: String? = null,
onPairUriConsumed: () -> Unit = {},
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val introSeen by prefs.introSeen.collectAsState(initial = null)
val activeServer by prefs.activeServer.collectAsState(initial = null)
// Pairing entry from a deep link that carried no password — prefills the
// connect form so the user lands on the password prompt for that server.
var pairPrefill by remember { mutableStateOf<ServerEntry?>(null) }
if (introSeen == null) return
// Declared after the introSeen gate so it can't fire before the NavHost
// below has set the nav graph; pairUri stays pending until consumed here.
LaunchedEffect(pairUri) {
val raw = pairUri ?: return@LaunchedEffect
onPairUriConsumed()
when (val result = ServerQrParser.parse(raw)) {
is PairResult.Success -> {
// Pairing implies the app is installed and in use — skip the intro.
prefs.markIntroSeen()
val merged = prefs.upsertServer(result.server)
if (merged.password.isNotBlank()) {
// Demo flow: password came with the link — connect in one step.
prefs.setActiveServer(merged)
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
} else {
pairPrefill = merged
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
}
else -> {
// Invalid or too-new pairing link — ignore; normal startup continues.
}
}
}
val startDestination = when {
introSeen == false -> Routes.INTRO
activeServer != null -> Routes.WEB_VIEW
else -> Routes.SERVER_CONNECT
}
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(Routes.INTRO) {
IntroScreen(
onContinue = {
scope.launch {
prefs.markIntroSeen()
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(Routes.INTRO) { inclusive = true }
}
}
},
)
}
composable(Routes.SERVER_CONNECT) {
ServerConnectScreen(
onConnected = { _ ->
navController.navigate(Routes.WEB_VIEW) {
popUpTo(Routes.SERVER_CONNECT) { inclusive = true }
}
},
initialServer = pairPrefill,
)
}
composable(Routes.WEB_VIEW) {
val server = activeServer
if (server == null) {
ServerConnectScreen(
onConnected = { _ ->
navController.navigate(Routes.WEB_VIEW) {
popUpTo(0) { inclusive = true }
}
},
)
} else {
WebViewScreen(
serverUrl = server.toUrl(),
serverPassword = server.password,
onDisconnect = {
scope.launch {
prefs.clearActiveServer()
navController.navigate(Routes.SERVER_CONNECT) {
popUpTo(0) { inclusive = true }
}
}
},
onRemoteInput = {
navController.navigate(Routes.REMOTE_INPUT)
},
)
}
}
composable(Routes.REMOTE_INPUT) {
RemoteInputScreen(
onBack = {
navController.popBackStack()
},
)
}
}
}
@@ -0,0 +1,243 @@
package com.archipelago.app.ui.screens
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
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.mutableStateOf
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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.delay
@Composable
fun IntroScreen(onContinue: () -> Unit) {
val logoAlpha = remember { Animatable(0f) }
var showContent by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
logoAlpha.animateTo(1f, animationSpec = tween(800))
delay(300)
showContent = true
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the title/buttons stay legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.55f),
Color.Black.copy(alpha = 0.35f),
Color.Black.copy(alpha = 0.75f),
),
)
),
)
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier
.size(160.dp)
.alpha(logoAlpha.value),
)
Spacer(modifier = Modifier.height(48.dp))
AnimatedVisibility(
visible = showContent,
enter = fadeIn(tween(600)) + slideInVertically(
initialOffsetY = { it / 4 },
animationSpec = tween(600),
),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = stringResource(R.string.welcome_title),
style = MaterialTheme.typography.headlineLarge,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.welcome_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
lineHeight = 26.sp,
)
Spacer(modifier = Modifier.height(48.dp))
GlassButton(
text = stringResource(R.string.get_started),
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
}
}
}
}
/** The pixel-art "A" from AnimatedLogo.vue — 20 white squares */
@Composable
fun PixelArtLogo(modifier: Modifier = Modifier) {
Canvas(modifier = modifier) {
val s = size.width / 1024f
val rects = listOf(
floatArrayOf(357.614f, 318f, 71.007f, 70.936f),
floatArrayOf(436.152f, 318f, 72.082f, 70.936f),
floatArrayOf(515.766f, 318f, 72.082f, 70.936f),
floatArrayOf(595.379f, 318f, 71.007f, 70.936f),
floatArrayOf(595.379f, 396.46f, 71.007f, 72.011f),
floatArrayOf(673.917f, 396.46f, 72.083f, 72.011f),
floatArrayOf(278f, 475.994f, 72.083f, 72.012f),
floatArrayOf(357.614f, 475.994f, 71.007f, 72.012f),
floatArrayOf(436.152f, 475.994f, 72.082f, 72.012f),
floatArrayOf(515.766f, 475.994f, 72.082f, 72.012f),
floatArrayOf(595.379f, 475.994f, 71.007f, 72.012f),
floatArrayOf(673.917f, 475.994f, 72.083f, 72.012f),
floatArrayOf(278f, 555.529f, 72.083f, 70.936f),
floatArrayOf(357.614f, 555.529f, 71.007f, 70.936f),
floatArrayOf(595.379f, 555.529f, 71.007f, 70.936f),
floatArrayOf(673.917f, 555.529f, 72.083f, 70.936f),
floatArrayOf(357.614f, 633.989f, 71.007f, 72.011f),
floatArrayOf(436.152f, 633.989f, 72.082f, 72.011f),
floatArrayOf(515.766f, 633.989f, 72.082f, 72.011f),
floatArrayOf(595.379f, 633.989f, 71.007f, 72.011f),
)
for (r in rects) {
drawRect(
color = Color.White,
topLeft = Offset(r[0] * s, r[1] * s),
size = Size(r[2] * s, r[3] * s),
)
}
}
}
/**
* Glass-style button matching Archipelago's .glass-button.
* Custom press state (subtle brighten) instead of Material ripple.
*/
@Composable
fun GlassButton(
text: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val pressAlpha by animateFloatAsState(
targetValue = if (isPressed) 1f else 0f,
animationSpec = tween(if (isPressed) 0 else 150),
label = "press",
)
// Lerp between rest and pressed states
val bgTop = 0.12f + pressAlpha * 0.08f // 0.12 → 0.20
val bgBottom = 0.04f + pressAlpha * 0.06f // 0.04 → 0.10
val borderA = 0.15f + pressAlpha * 0.10f // 0.15 → 0.25
val textAlpha = 1f - pressAlpha * 0.2f // 1.0 → 0.8
Box(
modifier = modifier
.clip(RoundedCornerShape(12.dp))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = bgTop),
Color.White.copy(alpha = bgBottom),
),
)
)
.border(
width = 1.dp,
color = Color.White.copy(alpha = borderA),
shape = RoundedCornerShape(12.dp),
)
.clickable(
interactionSource = interactionSource,
indication = null,
onClick = onClick,
),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
color = Color.White.copy(alpha = textAlpha),
style = MaterialTheme.typography.labelLarge,
fontSize = 16.sp,
)
}
}
@@ -0,0 +1,268 @@
package com.archipelago.app.ui.screens
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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 androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket
import com.archipelago.app.ui.components.NESController
import com.archipelago.app.ui.components.NESKeyboard
import com.archipelago.app.ui.components.NESMenu
import com.archipelago.app.ui.components.NESPortraitController
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.components.Trackpad
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
fun RemoteInputScreen(onBack: () -> Unit) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
val activeServer by prefs.activeServer.collectAsState(initial = null)
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var showQrScanner by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) }
// When the kiosk forwards an "open in external browser" app, launch it in
// the phone's default browser.
DisposableEffect(ws) {
ws.onExternalOpen = { url ->
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
context.startActivity(intent)
} catch (_: Exception) {}
}
onDispose { ws.onExternalOpen = null }
}
fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
}
val connectionState by ws.state.collectAsState()
val lifecycleOwner = LocalLifecycleOwner.current
BackHandler { onBack() }
// Connect on server change + reconnect when app resumes from background
DisposableEffect(lifecycleOwner, activeServer) {
val server = activeServer
if (server != null) {
ws.connect(server.toUrl(), server.password)
}
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && server != null) {
val state = ws.state.value
if (state != ConnectionState.CONNECTED && state != ConnectionState.CONNECTING) {
ws.connect(server.toUrl(), server.password)
}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
ws.disconnect()
}
}
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0C0C0C)),
) {
// Reddish synthwave backdrop behind the controller
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Light scrim — the controller body provides its own contrast, so keep
// this subtle and let the backdrop show through around it.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.45f),
),
)
),
)
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
isGamepadMode && !isLandscape -> NESPortraitController(
style = controllerStyle,
playerId = playerId,
onKey = { ws.sendKey(it) },
onMouseMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onMouseClick = { ws.sendClick(it) },
onMouseScroll = { ws.sendScroll(it) },
onMenu = { showModal = true },
onPlayerToggle = ::togglePlayer,
)
else -> {
// Keyboard mode: trackpad fills top, keyboard pinned bottom
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Trackpad(
onMove = { dx, dy -> ws.sendMouseMove(dx, dy) },
onClick = { ws.sendClick(it) },
onScroll = { ws.sendScroll(it) },
onTwoFingerHold = { showModal = true },
modifier = Modifier.fillMaxWidth().weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
NESKeyboard(
style = controllerStyle,
onKey = { ws.sendKey(it) },
modifier = Modifier.fillMaxWidth(),
)
}
// Settings icon top-right in keyboard mode
com.archipelago.app.ui.components.SettingsBtn(
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
onClick = { showModal = true },
)
}
}
}
// Connection dot
Box(
Modifier.align(Alignment.TopStart).padding(6.dp).size(8.dp)
.clip(CircleShape).background(
when (connectionState) {
ConnectionState.CONNECTED -> SuccessGreen
ConnectionState.CONNECTING -> BitcoinOrange
ConnectionState.ERROR, ConnectionState.AUTH_FAILED -> ErrorRed
ConnectionState.DISCONNECTED -> TextMuted
}
),
)
}
NESMenu(
visible = showModal,
servers = savedServers,
activeServer = activeServer,
isGamepadMode = isGamepadMode,
controllerStyle = controllerStyle,
onDismiss = { showModal = false },
onSelectServer = { server ->
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
},
onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
onScanQr = { showQrScanner = true },
onEditServer = { original, updated ->
scope.launch {
prefs.updateSavedServer(original, updated)
// If the edited server is the live one, reconnect with the new
// address/credentials so the change takes effect immediately.
if (original.serialize() == activeServer?.serialize()) {
ws.disconnect()
prefs.setActiveServer(updated)
}
}
},
onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Deleting the last server leaves nothing to control — drop the
// active server and return to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
ws.disconnect()
prefs.clearActiveServer()
showModal = false
onBack()
}
}
},
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
},
onBackToWebView = { showModal = false; onBack() },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
// open behind the scanner so the new entry appears as soon as it closes.
QrScannerOverlay(
visible = showQrScanner,
onDismiss = { showQrScanner = false },
onServerScanned = { server ->
showQrScanner = false
scope.launch {
val merged = prefs.upsertServer(server)
if (activeServer == null) prefs.setActiveServer(merged)
}
},
)
}
}
@@ -0,0 +1,684 @@
package com.archipelago.app.ui.screens
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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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 androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.R
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.components.QrScannerOverlay
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.SurfaceCard
import com.archipelago.app.ui.theme.SuccessGreen
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.archipelago.app.ui.theme.TextSecondary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.X509TrustManager
@Composable
fun ServerConnectScreen(
onConnected: (String) -> Unit,
onRemoteInput: () -> Unit = {},
// Prefill from a pairing deep link (archipelago://pair) that carried no
// password — opens the manual form on the password prompt for that server.
initialServer: ServerEntry? = null,
) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
var useHttps by remember { mutableStateOf(false) }
var isConnecting by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
// The saved server currently being edited, or null when adding/connecting.
var editingServer by remember { mutableStateOf<ServerEntry?>(null) }
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
var manualMode by remember { mutableStateOf(false) }
var showScanner by remember { mutableStateOf(false) }
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
fun clearForm() {
name = ""
address = ""
port = ""
password = ""
useHttps = false
passwordVisible = false
errorMessage = null
}
fun startEdit(server: ServerEntry) {
editingServer = server
name = server.name
address = server.address
port = server.port
password = server.password
useHttps = server.useHttps
passwordVisible = false
errorMessage = null
}
fun cancelEdit() {
editingServer = null
clearForm()
}
fun saveEdit() {
val original = editingServer ?: return
if (address.isBlank()) {
errorMessage = "Enter a server address"
return
}
val updated = ServerEntry(address, useHttps, port, password, name)
scope.launch {
prefs.updateSavedServer(original, updated)
cancelEdit()
}
}
fun connect(server: ServerEntry) {
if (isConnecting) return
if (server.address.isBlank()) {
errorMessage = "Enter a server address"
return
}
isConnecting = true
errorMessage = null
scope.launch {
val result = testConnection(server)
isConnecting = false
if (result) {
prefs.setActiveServer(server)
onConnected(server.toUrl())
} else {
errorMessage = context.getString(R.string.connection_failed)
}
}
}
fun prefill(server: ServerEntry) {
name = server.name
address = server.address
port = server.port
password = server.password
useHttps = server.useHttps
}
// Pairing QR scanned: dedupe against saved servers, then either auto-connect
// (payload carried a password — the demo flow) or land on the password
// prompt with everything else filled in (real nodes never embed one).
fun onQrScanned(scanned: ServerEntry) {
showScanner = false
scope.launch {
val merged = prefs.upsertServer(scanned)
prefill(merged)
if (merged.password.isNotBlank()) {
connect(merged)
} else {
manualMode = true
}
}
}
LaunchedEffect(initialServer) {
if (initialServer != null) {
prefill(prefs.upsertServer(initialServer))
manualMode = true
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the form stays legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.6f),
Color.Black.copy(alpha = 0.45f),
Color.Black.copy(alpha = 0.8f),
),
)
),
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(state = rememberScrollState())
.drawWithContent { drawContent() }
.padding(horizontal = 24.dp)
.padding(top = 48.dp, bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
// Center the content vertically — the landing (logo + two buttons) is
// short and looks stranded at the top otherwise. Taller content (the
// manual form, saved servers) still scrolls from the top as normal.
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
) {
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier.size(96.dp),
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
val showForm = manualMode || editingServer != null
Text(
text = if (showForm) stringResource(R.string.server_address_hint) else stringResource(R.string.connect_landing_hint),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(4.dp))
if (!showForm) {
// Landing: scan the pairing QR, or fall back to manual entry
GlassButton(
text = stringResource(R.string.scan_node_qr),
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
GlassButton(
text = stringResource(R.string.enter_manually),
onClick = {
errorMessage = null
manualMode = true
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
}
// Glass card with form
if (showForm) Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.06f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
Column {
OutlinedTextField(
value = name,
onValueChange = {
name = it
errorMessage = null
},
label = { Text(stringResource(R.string.server_name_label)) },
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = address,
onValueChange = {
address = sanitizeAddress(it)
errorMessage = null
},
label = { Text(stringResource(R.string.server_address_label)) },
placeholder = { Text(stringResource(R.string.server_address_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = port,
onValueChange = {
port = it.filter { c -> c.isDigit() }.take(5)
errorMessage = null
},
label = { Text(stringResource(R.string.port_label)) },
placeholder = { Text("80") },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
OutlinedTextField(
value = password,
onValueChange = {
password = it
errorMessage = null
},
label = { Text("Password") },
modifier = Modifier.weight(2f),
singleLine = true,
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (passwordVisible) "Hide password" else "Show password",
tint = TextMuted,
modifier = Modifier.size(20.dp),
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(
onGo = {
keyboard?.hide()
if (editingServer != null) {
saveEdit()
} else {
connect(ServerEntry(address, useHttps, port, password, name))
}
},
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (useHttps) SuccessGreen else TextMuted,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.use_https),
style = MaterialTheme.typography.bodyMedium,
color = TextSecondary,
)
}
Switch(
checked = useHttps,
onCheckedChange = { useHttps = it },
colors = SwitchDefaults.colors(
checkedThumbColor = SurfaceBlack,
checkedTrackColor = BitcoinOrange,
uncheckedThumbColor = TextMuted,
uncheckedTrackColor = SurfaceCard,
),
)
}
}
}
// Error
AnimatedVisibility(visible = errorMessage != null, enter = fadeIn(), exit = fadeOut()) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(ErrorRed.copy(alpha = 0.12f))
.border(1.dp, ErrorRed.copy(alpha = 0.25f), RoundedCornerShape(12.dp))
.padding(12.dp),
) {
Text(text = errorMessage ?: "", color = ErrorRed, style = MaterialTheme.typography.bodyMedium)
}
}
if (editingServer != null) {
// Save / Cancel while editing an existing saved server
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GlassButton(
text = stringResource(R.string.cancel),
onClick = {
keyboard?.hide()
cancelEdit()
},
modifier = Modifier.weight(1f).height(56.dp),
)
GlassButton(
text = stringResource(R.string.save_changes),
onClick = {
keyboard?.hide()
saveEdit()
},
modifier = Modifier.weight(1f).height(56.dp),
)
}
} else if (manualMode) {
// Back to the Scan/Manual landing + Connect
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
GlassButton(
text = stringResource(R.string.back),
onClick = {
keyboard?.hide()
manualMode = false
clearForm()
},
modifier = Modifier.weight(1f).height(56.dp),
)
GlassButton(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.weight(2f).height(56.dp),
)
}
}
if (isConnecting) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.White.copy(alpha = 0.6f),
strokeWidth = 2.dp,
)
}
// Saved servers (hidden while editing one to keep focus on the form)
if (editingServer == null && savedServers.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.saved_servers),
style = MaterialTheme.typography.labelMedium,
color = TextMuted,
letterSpacing = 1.sp,
modifier = Modifier.fillMaxWidth(),
)
savedServers.forEach { server ->
SavedServerItem(
server = server,
onConnect = { connect(it) },
onEdit = { startEdit(it) },
onRemove = { scope.launch { prefs.removeSavedServer(it) } },
)
}
}
}
QrScannerOverlay(
visible = showScanner,
onDismiss = { showScanner = false },
onServerScanned = { onQrScanned(it) },
)
}
}
@Composable
private fun SavedServerItem(
server: ServerEntry,
onConnect: (ServerEntry) -> Unit,
onEdit: (ServerEntry) -> Unit,
onRemove: (ServerEntry) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
Color.White.copy(alpha = 0.06f),
Color.White.copy(alpha = 0.02f),
),
)
)
.border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(12.dp))
.clickable { onConnect(server) }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
Icon(
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
val secondary = buildString {
if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
IconButton(onClick = { onEdit(server) }) {
Icon(imageVector = Icons.Default.Edit, contentDescription = stringResource(R.string.edit_server), modifier = Modifier.size(18.dp), tint = TextMuted)
}
IconButton(onClick = { onRemove(server) }) {
Icon(imageVector = Icons.Default.Close, contentDescription = stringResource(R.string.remove_server), modifier = Modifier.size(18.dp), tint = TextMuted)
}
}
}
/** Strip protocol prefixes and trailing slashes from address input. */
private fun sanitizeAddress(input: String): String {
return input.trim()
.removePrefix("https://")
.removePrefix("http://")
.trimEnd('/')
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
private suspend fun testConnection(server: ServerEntry): Boolean {
return withContext(Dispatchers.IO) {
try {
val url = URL("${server.toUrl()}/rpc/v1")
val connection = url.openConnection() as HttpURLConnection
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
if (connection is HttpsURLConnection) {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
connection.sslSocketFactory = sc.socketFactory
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
connection.requestMethod = "POST"
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
connection.outputStream.use { it.write(body.toByteArray()) }
val code = connection.responseCode
connection.disconnect()
code in 200..499
} catch (_: Exception) {
false
}
}
}
@@ -0,0 +1,748 @@
package com.archipelago.app.ui.screens
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.ViewGroup
import android.webkit.CookieManager
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 androidx.activity.compose.BackHandler
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
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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 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.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
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
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.archipelago.app.R
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) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply {
// Required when launching from a non-Activity/binder thread
// (the JS bridge below can run off the UI thread).
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
/** True when [url] points at the same host as the connected Archipelago node
* (ignoring port). Such URLs are node apps — e.g. one that can't be iframed —
* and should stay inside the app rather than bouncing out to the browser. */
private fun isSameHost(url: String, base: String): Boolean {
return try {
val a = android.net.Uri.parse(url).host ?: return false
val b = android.net.Uri.parse(base).host ?: return false
a.equals(b, ignoreCase = true)
} catch (_: Exception) {
false
}
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
* These are tuned for SPA performance and parity with the mobile browser;
* none of them alter how a page renders visually. */
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.applyArchipelagoSettings() {
// Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't
// show blank checkerboarding — the single biggest scroll-smoothness win and
// a major part of the "feels slower than the browser" gap. (API 23+)
settings.setOffscreenPreRaster(true)
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
mediaPlaybackRequiresUserGesture = false
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(false)
builtInZoomControls = false
cacheMode = WebSettings.LOAD_DEFAULT
allowContentAccess = true
allowFileAccess = false
}
// chrome://inspect profiling on debuggable builds only — lets us measure the
// real in-page bottleneck rather than guess. No effect on release builds.
val debuggable = 0 != (context.applicationInfo.flags and
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
}
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
@Composable
fun WebViewScreen(
serverUrl: String,
onDisconnect: () -> Unit,
onRemoteInput: () -> Unit = {},
// Stored password for this server (from QR pairing or manual entry). When
// non-blank, the login page is auto-filled and submitted — the one-step
// demo flow from docs/companion-pairing-qr.md.
serverPassword: String = "",
) {
var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) }
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
// 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.
var inAppUrl by remember { mutableStateOf<String?>(null) }
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
webView?.goBack()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
) {
if (hasError) {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Default.CloudOff,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = TextMuted,
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.server_unreachable),
style = MaterialTheme.typography.headlineMedium,
color = TextPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.connection_failed),
style = MaterialTheme.typography.bodyMedium,
color = TextMuted,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(32.dp))
GlassButton(
text = stringResource(R.string.retry),
onClick = {
hasError = false
isLoading = true
webView?.loadUrl(serverUrl)
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
GlassButton(
text = stringResource(R.string.disconnect),
onClick = onDisconnect,
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
val cookieManager = CookieManager.getInstance()
cookieManager.setAcceptCookie(true)
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
settings.apply {
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
// chain; without this, window.open() from a Vue click
// handler silently no-ops and "Open in new tab" dies.
javaScriptCanOpenWindowsAutomatically = true
}
val webViewRef = this
// Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the
// kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser
fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) {
inAppUrl = url
} else {
openExternalUrl(context, url)
}
}
// JS bridge. The web UI calls:
// window.ArchipelagoNative.openExternal(url) — host-routed
// window.ArchipelagoNative.openInApp(url) — force in-app
// Falls back to window.open in a plain mobile browser.
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
webViewRef.post { routeOutbound(url) }
}
@android.webkit.JavascriptInterface
fun openInApp(url: String) {
webViewRef.post { inAppUrl = url }
}
},
"ArchipelagoNative",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
isLoading = true
hasError = false
}
override fun onPageFinished(view: WebView?, url: String?) {
isLoading = false
if (view == null) return
// Convert physical pixels → CSS pixels
val density = view.resources.displayMetrics.density
val satPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.statusBars())
?.top ?: 0
val sabPx = view.rootWindowInsets
?.getInsets(android.view.WindowInsets.Type.navigationBars())
?.bottom ?: 0
val sat = (satPx / density).toInt()
val sab = (sabPx / density).toInt()
// Android WebView doesn't populate env(safe-area-inset-*).
// Set CSS custom properties the web UI can use as fallback:
// var(--safe-area-top, env(safe-area-inset-top, 0px))
view.evaluateJavascript(
"""
(function() {
var style = document.getElementById('archipelago-android-insets');
if (!style) {
style = document.createElement('style');
style.id = 'archipelago-android-insets';
document.head.appendChild(style);
}
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
})();
""".trimIndent(),
null,
)
// Auto-login with the stored password (QR pairing /
// saved server) — only on our own server's pages.
if (serverPassword.isNotBlank() && url != null && url.startsWith(serverUrl)) {
view.evaluateJavascript(buildAutoLoginScript(serverPassword), null)
}
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
if (request?.isForMainFrame == true) {
hasError = true
isLoading = false
}
}
// Node apps (e.g. NetBird) terminate TLS with a
// self-signed cert — the dashboard needs a secure
// context for OIDC/window.crypto.subtle (#15). The
// WebView default is to CANCEL untrusted certs, so
// those apps render blank. The user explicitly trusts
// their own node, so proceed for same-host certs only;
// reject anything else (don't blanket-trust the web).
override fun onReceivedSslError(
view: WebView?,
handler: android.webkit.SslErrorHandler?,
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
handler?.proceed()
} else {
handler?.cancel()
}
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return false
// Keep kiosk navigation (same origin incl. port) in place
if (url.startsWith(serverUrl)) return false
// Same node (other port) → in-app; external → browser
routeOutbound(url)
return true
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
}
// 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.
override fun onCreateWindow(
view: WebView?,
isDialog: Boolean,
isUserGesture: Boolean,
resultMsg: android.os.Message?,
): Boolean {
val transport = resultMsg?.obj as? WebView.WebViewTransport
?: return false
val popup = WebView(context).apply {
settings.javaScriptEnabled = true
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
routeOutbound(url)
return true
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (url != null) routeOutbound(url)
view?.stopLoading()
}
}
}
transport.webView = popup
resultMsg.sendToTarget()
return true
}
}
// Two-finger hold (500ms) → navigate to remote input
var twoFingerStart = 0L
var twoFingerFired = false
setOnTouchListener { _, event ->
val pointerCount = event.pointerCount
when (event.actionMasked) {
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
if (pointerCount >= 2) {
twoFingerStart = System.currentTimeMillis()
twoFingerFired = false
}
}
android.view.MotionEvent.ACTION_MOVE -> {
if (pointerCount >= 2 && !twoFingerFired && twoFingerStart > 0) {
if (System.currentTimeMillis() - twoFingerStart > 500) {
twoFingerFired = true
onRemoteInput()
}
}
}
android.view.MotionEvent.ACTION_UP,
android.view.MotionEvent.ACTION_POINTER_UP,
android.view.MotionEvent.ACTION_CANCEL -> {
if (event.pointerCount <= 2) {
twoFingerStart = 0L
}
}
}
false // don't consume — let WebView handle normally
}
webView = this
loadUrl(serverUrl)
}
},
)
// Loading bar at top edge
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
) {
LinearProgressIndicator(
progress = { loadProgress / 100f },
modifier = Modifier.fillMaxWidth(),
color = BitcoinOrange,
trackColor = SurfaceBlack,
)
}
// In-app browser overlay for non-iframeable node apps. Rendered last
// so it sits above the kiosk WebView, which stays alive underneath.
inAppUrl?.let { target ->
InAppBrowser(
url = target,
serverUrl = serverUrl,
onClose = { inAppUrl = null },
)
}
}
}
}
/** 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 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
private fun InAppBrowser(
url: String,
serverUrl: String,
onClose: () -> Unit,
) {
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) }
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 && favicon == null) favicon = fetched
}
// Back: walk the in-app history first, then close the overlay.
BackHandler {
val b = browser
if (b != null && b.canGoBack()) b.goBack() else onClose()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
// WebView + loading overlay fill the area above the bottom control bar.
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
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
}
override fun onReceivedIcon(view: WebView?, icon: Bitmap?) {
if (icon != null) favicon = icon
}
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
}
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
}
// Self-signed TLS on the node's apps (e.g. NetBird on
// :8087) would otherwise be cancelled by the WebView
// and render blank. Proceed for the user's own node
// (same host); reject any other untrusted cert.
override fun onReceivedSslError(
view: WebView?,
handler: android.webkit.SslErrorHandler?,
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
handler?.proceed()
} else {
handler?.cancel()
}
}
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)
}
},
)
// Centered loading screen — app favicon (or spinner) + title + bar.
if (loading) {
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
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(18.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.width(220.dp),
color = BitcoinOrange,
trackColor = TextMuted.copy(alpha = 0.2f),
)
}
}
}
// Bottom control bar — mirrors the web mobile-iframe footer.
Row(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.background(SurfaceBlack)
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically,
) {
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,
)
}
}
}
}
/**
* JS that fills the web UI login form (Login.vue's #login-password) with the
* stored password and submits it once the form is interactive — the one-step
* pairing flow. No-ops when the login step never appears (already
* authenticated, first-boot setup, TOTP). At most two attempts per page load,
* then it stops for good so a wrong stored password can't spam the node.
*/
private fun buildAutoLoginScript(password: String): String {
val quoted = org.json.JSONObject.quote(password)
return """
(function () {
if (window.__archyAutoLogin) return;
window.__archyAutoLogin = true;
var pw = $quoted;
var attempts = 0;
var started = Date.now();
var timer = setInterval(function () {
if (Date.now() - started > 45000) { clearInterval(timer); return; }
var el = document.getElementById('login-password');
if (!el) {
// Field gone after we submitted = success or step change - stop.
if (attempts > 0) clearInterval(timer);
return;
}
if (el.disabled) return; // form waits on serverReady
if (attempts >= 2) { clearInterval(timer); return; }
attempts++;
var setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(el, pw);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
}, 1500);
})();
""".trimIndent()
}
@@ -0,0 +1,24 @@
package com.archipelago.app.ui.theme
import androidx.compose.ui.graphics.Color
// Archipelago brand palette — Bitcoin orange on dark
val BitcoinOrange = Color(0xFFF7931A)
val BitcoinOrangeLight = Color(0xFFFFB74D)
val BitcoinOrangeDark = Color(0xFFE07C00)
val SurfaceBlack = Color(0xFF000000)
val SurfaceDark = Color(0xFF0A0A0A)
val SurfaceCard = Color(0xFF1A1A1A)
val SurfaceCardHover = Color(0xFF222222)
val SurfaceElevated = Color(0xFF2A2A2A)
val TextPrimary = Color(0xFFF5F5F5)
val TextSecondary = Color(0xFFB0B0B0)
val TextMuted = Color(0xFF666666)
val BorderSubtle = Color(0xFF2A2A2A)
val BorderDefault = Color(0xFF3A3A3A)
val ErrorRed = Color(0xFFEF4444)
val SuccessGreen = Color(0xFF22C55E)
@@ -0,0 +1,44 @@
package com.archipelago.app.ui.theme
import androidx.compose.ui.graphics.Color
/** NES/8BitDo controller palettes */
object NES {
// ── Classic (light body, red buttons) ──────────────
val ClassicBody = Color(0xFFD4D0C8) // warm light gray plastic
val ClassicFace = Color(0xFF1C1C1C) // dark face plate
val ClassicAccent = Color(0xFF8A8A8A) // mid gray trim
val ClassicRidge = Color(0xFFBBB8B0) // grip lines
val ClassicButtonRed = Color(0xFFC1121C) // A/B red
val ClassicButtonRedPress = Color(0xFF8A0D14)
val ClassicButtonGray = Color(0xFF5A5A5A) // turbo buttons
val ClassicButtonGrayPress = Color(0xFF3A3A3A)
val ClassicDPad = Color(0xFF1A1A1A)
val ClassicDPadPress = Color(0xFF2A2A2A)
val ClassicLabel = Color(0xFFC1121C) // red text labels
val ClassicLabelMuted = Color(0xFF6A6A6A)
val ClassicSelect = Color(0xFF2A2A2A) // START/SELECT
// ── Transparent Dark ───────────────────────────────
val DarkBody = Color(0xFF2A2A2E) // smoky translucent dark
val DarkFace = Color(0xFF151518) // darker face
val DarkAccent = Color(0xFF3A3A3E) // trim
val DarkRidge = Color(0xFF222226) // grip lines
val DarkButtonMain = Color(0xFF3A3A3E) // all buttons dark
val DarkButtonMainPress = Color(0xFF222226)
val DarkDPad = Color(0xFF0E0E10)
val DarkDPadPress = Color(0xFF1A1A1E)
val DarkLabel = Color(0xFF5A5A60) // muted labels
val DarkLabelMuted = Color(0xFF3A3A3E)
val DarkSelect = Color(0xFF1A1A1E)
// ── Menu UI (NES-style) ────────────────────────────
val MenuBg = Color(0xFF000000)
val MenuPanel = Color(0xFF0B1B4A) // dark navy
val MenuBorder = Color(0xFFFFFFFF)
val MenuText = Color(0xFFFFFFFF)
val MenuSelected = Color(0xFFC1121C)
val MenuMuted = Color(0xFF7A7A7A)
}
enum class ControllerStyle { CLASSIC, DARK }
@@ -0,0 +1,106 @@
package com.archipelago.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
object Neo {
// ── Dark ───────────────────────────────────────────
val DarkSurface = Color(0xFF0A0A0A)
val DarkSurfaceRaised = Color(0xFF0F0F11)
val DarkShadowLight = Color(0xFF151517)
val DarkShadowDark = Color(0xFF000000)
val DarkBorder = Color(0x0AFFFFFF)
// ── Light ──────────────────────────────────────────
val LightSurface = Color(0xFFE0E0E4)
val LightSurfaceRaised = Color(0xFFE6E6EA)
val LightShadowLight = Color(0xFFF2F2F6)
val LightShadowDark = Color(0xFFB4B4BA)
val LightBorder = Color(0x0A000000)
val LightTextPrimary = Color(0xFF141414)
val LightTextSecondary = Color(0xFF5A5A5A)
val LightTextMuted = Color(0xFF9A9A9A)
// ── Accessors ──────────────────────────────────────
@Composable @ReadOnlyComposable
fun surface() = if (isSystemInDarkTheme()) DarkSurface else LightSurface
@Composable @ReadOnlyComposable
fun surfaceRaised() = if (isSystemInDarkTheme()) DarkSurfaceRaised else LightSurfaceRaised
@Composable @ReadOnlyComposable
fun shadowLight() = if (isSystemInDarkTheme()) DarkShadowLight else LightShadowLight
@Composable @ReadOnlyComposable
fun shadowDark() = if (isSystemInDarkTheme()) DarkShadowDark else LightShadowDark
@Composable @ReadOnlyComposable
fun border() = if (isSystemInDarkTheme()) DarkBorder else LightBorder
@Composable @ReadOnlyComposable
fun textPrimary() = if (isSystemInDarkTheme()) Color(0xFFD0D0D0) else LightTextPrimary
@Composable @ReadOnlyComposable
fun textSecondary() = if (isSystemInDarkTheme()) Color(0xFF666666) else LightTextSecondary
@Composable @ReadOnlyComposable
fun textMuted() = if (isSystemInDarkTheme()) Color(0xFF333333) else LightTextMuted
}
/** Subtle neomorphic raised shadow */
fun Modifier.neoRaised(
lightShadow: Color,
darkShadow: Color,
radius: Dp = 14.dp,
shadowOffset: Dp = 2.dp,
shadowBlur: Dp = 4.dp,
) = this.drawBehind {
val r = radius.toPx()
val off = shadowOffset.toPx()
val blur = shadowBlur.toPx()
drawIntoCanvas { canvas ->
val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) }
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, darkShadow.toArgb()) }
})
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, lightShadow.toArgb()) }
})
}
}
/** Subtle neomorphic inset shadow */
fun Modifier.neoInset(
lightShadow: Color,
darkShadow: Color,
radius: Dp = 14.dp,
shadowOffset: Dp = 1.dp,
shadowBlur: Dp = 3.dp,
) = this.drawBehind {
val r = radius.toPx()
val off = shadowOffset.toPx()
val blur = shadowBlur.toPx()
drawIntoCanvas { canvas ->
val path = Path().apply { addRoundRect(RoundRect(0f, 0f, size.width, size.height, CornerRadius(r))) }
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, -off, -off, darkShadow.toArgb()) }
})
canvas.drawPath(path, Paint().also {
it.asFrameworkPaint().apply { isAntiAlias = true; color = android.graphics.Color.TRANSPARENT; setShadowLayer(blur, off, off, lightShadow.toArgb()) }
})
}
}
@@ -0,0 +1,56 @@
package com.archipelago.app.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val DarkColorScheme = darkColorScheme(
primary = BitcoinOrange,
onPrimary = SurfaceBlack,
primaryContainer = BitcoinOrangeDark,
onPrimaryContainer = TextPrimary,
secondary = BitcoinOrangeLight,
onSecondary = SurfaceBlack,
background = SurfaceBlack,
onBackground = TextPrimary,
surface = SurfaceDark,
onSurface = TextPrimary,
surfaceVariant = SurfaceCard,
onSurfaceVariant = TextSecondary,
outline = BorderDefault,
outlineVariant = BorderSubtle,
error = ErrorRed,
onError = TextPrimary,
)
private val LightColorScheme = lightColorScheme(
primary = BitcoinOrange,
onPrimary = SurfaceBlack,
primaryContainer = BitcoinOrangeLight,
onPrimaryContainer = SurfaceBlack,
secondary = BitcoinOrangeDark,
onSecondary = TextPrimary,
background = Neo.LightSurface,
onBackground = Neo.LightTextPrimary,
surface = Neo.LightSurfaceRaised,
onSurface = Neo.LightTextPrimary,
surfaceVariant = Neo.LightSurface,
onSurfaceVariant = Neo.LightTextSecondary,
outline = Neo.LightBorder,
outlineVariant = Neo.LightBorder,
error = ErrorRed,
onError = TextPrimary,
)
@Composable
fun ArchipelagoTheme(content: @Composable () -> Unit) {
val colorScheme = if (isSystemInDarkTheme()) DarkColorScheme else LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content,
)
}
@@ -0,0 +1,60 @@
package com.archipelago.app.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = (-0.5).sp,
),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 20.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
bodyLarge = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp,
),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Whole badge lives here (background renders to the mask edge with no
safe-zone cropping, unlike the foreground): dark fill + metallic ring pulled
inward to ~0.88 so the mask can't clip it + grid at ~0.58. Matches the
locally-rendered preview. Foreground is transparent. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="752"
android:viewportHeight="752">
<path
android:fillColor="#0A0A0A"
android:pathData="M0,0h752v752H0z" />
<!-- Ring matching logo.svg's gradient (#000->#666). Scale 0.65 places it at
the home-screen's visible edge (calibrated from a device home screenshot;
launcher3 crops less than the Settings App-info view). -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.65"
android:scaleY="0.65">
<path
android:fillColor="#00000000"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0.000976562">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
</group>
<!-- White Archipelago grid -->
<group
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.55"
android:scaleY="0.55">
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</group>
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Transparent — the whole badge (ring + grid) is in the background layer so it
renders to the mask edge without safe-zone cropping. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#00000000"
android:pathData="M0,0h108v108H0z" />
</vector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago circular badge logo (from logo.svg):
dark circle with a black→grey gradient ring + white pixel-grid mark. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="120dp"
android:height="120dp"
android:viewportWidth="752"
android:viewportHeight="752">
<!-- Ringed circle (circle converted to a path; stroke carries the gradient) -->
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
<!-- White Archipelago pixel grid -->
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</vector>
@@ -0,0 +1,51 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="240dp"
android:height="30dp"
android:viewportWidth="2079"
android:viewportHeight="263">
<!-- A -->
<path android:fillColor="#FFFFFF"
android:pathData="M29.6,85.6V59.2H56V85.6H29.6ZM58.8,85.6V59.2H85.6V85.6H58.8ZM88.4,85.6V59.2H115.2V85.6H88.4ZM118,85.6V59.2H144.4V85.6H118ZM118,115.2V88.4H144.4V115.2H118ZM147.2,115.2V88.4H174V115.2H147.2ZM0,144.8V118H26.8V144.8H0ZM29.6,144.8V118H56V144.8H29.6ZM58.8,144.8V118H85.6V144.8H58.8ZM88.4,144.8V118H115.2V144.8H88.4ZM118,144.8V118H144.4V144.8H118ZM147.2,144.8V118H174V144.8H147.2ZM0,174V147.6H26.8V174H0ZM29.6,174V147.6H56V174H29.6ZM118,174V147.6H144.4V174H118ZM147.2,174V147.6H174V174H147.2ZM29.6,203.6V176.8H56V203.6H29.6ZM58.8,203.6V176.8H85.6V203.6H58.8ZM88.4,203.6V176.8H115.2V203.6H88.4ZM118,203.6V176.8H144.4V203.6H118Z" />
<!-- R -->
<path android:fillColor="#FFFFFF"
android:pathData="M243.663,85.6V59.2H270.062V85.6H243.663ZM272.863,85.6V59.2H299.663V85.6H272.863ZM302.463,85.6V59.2H329.263V85.6H302.463ZM332.062,85.6V59.2H358.462V85.6H332.062ZM332.062,115.2V88.4H358.462V115.2H332.062ZM361.263,115.2V88.4H388.062V115.2H361.263ZM214.062,115.2V88.4H240.863V115.2H214.062ZM243.663,115.2V88.4H270.062V115.2H243.663ZM214.062,144.8V118H240.863V144.8H214.062ZM243.663,144.8V118H270.062V144.8H243.663ZM214.062,174V147.6H240.863V174H214.062ZM243.663,174V147.6H270.062V174H243.663ZM243.663,203.6V176.8H270.062V203.6H243.663Z" />
<!-- C -->
<path android:fillColor="#FFFFFF"
android:pathData="M457.725,85.6V59.2H484.125V85.6H457.725ZM486.925,85.6V59.2H513.725V85.6H486.925ZM516.525,85.6V59.2H543.325V85.6H516.525ZM546.125,85.6V59.2H572.525V85.6H546.125ZM428.125,115.2V88.4H454.925V115.2H428.125ZM457.725,115.2V88.4H484.125V115.2H457.725ZM546.125,115.2V88.4H572.525V115.2H546.125ZM575.325,115.2V88.4H602.125V115.2H575.325ZM428.125,144.8V118H454.925V144.8H428.125ZM457.725,144.8V118H484.125V144.8H457.725ZM428.125,174V147.6H454.925V174H428.125ZM457.725,174V147.6H484.125V174H457.725ZM546.125,174V147.6H572.525V174H546.125ZM575.325,174V147.6H602.125V174H575.325ZM457.725,203.6V176.8H484.125V203.6H457.725ZM486.925,203.6V176.8H513.725V203.6H486.925ZM516.525,203.6V176.8H543.325V203.6H516.525ZM546.125,203.6V176.8H572.525V203.6H546.125Z" />
<!-- H -->
<path android:fillColor="#FFFFFF"
android:pathData="M671.787,26.8V0H698.188V26.8H671.787ZM642.188,56.4V29.6H668.987V56.4H642.188ZM671.787,56.4V29.6H698.188V56.4H671.787ZM642.188,85.6V59.2H668.987V85.6H642.188ZM671.787,85.6V59.2H698.188V85.6H671.787ZM700.987,85.6V59.2H727.787V85.6H700.987ZM730.588,85.6V59.2H757.388V85.6H730.588ZM760.188,85.6V59.2H786.588V85.6H760.188ZM642.188,115.2V88.4H668.987V115.2H642.188ZM671.787,115.2V88.4H698.188V115.2H671.787ZM760.188,115.2V88.4H786.588V115.2H760.188ZM789.388,115.2V88.4H816.188V115.2H789.388ZM642.188,144.8V118H668.987V144.8H642.188ZM671.787,144.8V118H698.188V144.8H671.787ZM760.188,144.8V118H786.588V144.8H760.188ZM789.388,144.8V118H816.188V144.8H789.388ZM642.188,174V147.6H668.987V174H642.188ZM671.787,174V147.6H698.188V174H671.787ZM760.188,174V147.6H786.588V174H760.188ZM789.388,174V147.6H816.188V174H789.388ZM671.787,203.6V176.8H698.188V203.6H671.787ZM760.188,203.6V176.8H786.588V203.6H760.188Z" />
<!-- I -->
<path android:fillColor="#FFFFFF"
android:pathData="M856.25,26.8V0H883.05V26.8H856.25ZM885.85,26.8V0H912.25V26.8H885.85ZM856.25,85.6V59.2H883.05V85.6H856.25ZM856.25,115.2V88.4H883.05V115.2H856.25ZM885.85,115.2V88.4H912.25V115.2H885.85ZM856.25,144.8V118H883.05V144.8H856.25ZM885.85,144.8V118H912.25V144.8H885.85ZM856.25,174V147.6H883.05V174H856.25ZM885.85,174V147.6H912.25V174H885.85ZM885.85,203.6V176.8H912.25V203.6H885.85Z" />
<!-- P -->
<path android:fillColor="#FFFFFF"
android:pathData="M981.944,85.6V59.2H1008.34V85.6H981.944ZM1011.14,85.6V59.2H1037.94V85.6H1011.14ZM1040.74,85.6V59.2H1067.54V85.6H1040.74ZM1070.34,85.6V59.2H1096.74V85.6H1070.34ZM952.344,115.2V88.4H979.144V115.2H952.344ZM981.944,115.2V88.4H1008.34V115.2H981.944ZM1070.34,115.2V88.4H1096.74V115.2H1070.34ZM1099.54,115.2V88.4H1126.34V115.2H1099.54ZM952.344,144.8V118H979.144V144.8H952.344ZM981.944,144.8V118H1008.34V144.8H981.944ZM1070.34,144.8V118H1096.74V144.8H1070.34ZM1099.54,144.8V118H1126.34V144.8H1099.54ZM952.344,174V147.6H979.144V174H952.344ZM981.944,174V147.6H1008.34V174H981.944ZM1070.34,174V147.6H1096.74V174H1070.34ZM1099.54,174V147.6H1126.34V174H1099.54ZM952.344,203.6V176.8H979.144V203.6H952.344ZM981.944,203.6V176.8H1008.34V203.6H981.944ZM1011.14,203.6V176.8H1037.94V203.6H1011.14ZM1040.74,203.6V176.8H1067.54V203.6H1040.74ZM1070.34,203.6V176.8H1096.74V203.6H1070.34ZM952.344,233.2V206.4H979.144V233.2H952.344ZM981.944,233.2V206.4H1008.34V233.2H981.944ZM981.944,262.4V236H1008.34V262.4H981.944Z" />
<!-- E -->
<path android:fillColor="#FFFFFF"
android:pathData="M1196.01,85.6V59.2H1222.41V85.6H1196.01ZM1225.21,85.6V59.2H1252.01V85.6H1225.21ZM1254.81,85.6V59.2H1281.61V85.6H1254.81ZM1284.41,85.6V59.2H1310.81V85.6H1284.41ZM1166.41,115.2V88.4H1193.21V115.2H1166.41ZM1196.01,115.2V88.4H1222.41V115.2H1196.01ZM1284.41,115.2V88.4H1310.81V115.2H1284.41ZM1313.61,115.2V88.4H1340.41V115.2H1313.61ZM1166.41,144.8V118H1193.21V144.8H1166.41ZM1196.01,144.8V118H1222.41V144.8H1196.01ZM1225.21,144.8V118H1252.01V144.8H1225.21ZM1254.81,144.8V118H1281.61V144.8H1254.81ZM1284.41,144.8V118H1310.81V144.8H1284.41ZM1313.61,144.8V118H1340.41V144.8H1313.61ZM1166.41,174V147.6H1193.21V174H1166.41ZM1196.01,174V147.6H1222.41V174H1196.01ZM1196.01,203.6V176.8H1222.41V203.6H1196.01ZM1225.21,203.6V176.8H1252.01V203.6H1225.21ZM1254.81,203.6V176.8H1281.61V203.6H1254.81ZM1284.41,203.6V176.8H1310.81V203.6H1284.41Z" />
<!-- L -->
<path android:fillColor="#FFFFFF"
android:pathData="M1380.47,26.8V0H1407.27V26.8H1380.47ZM1380.47,56.4V29.6H1407.27V56.4H1380.47ZM1410.07,56.4V29.6H1436.47V56.4H1410.07ZM1380.47,85.6V59.2H1407.27V85.6H1380.47ZM1410.07,85.6V59.2H1436.47V85.6H1410.07ZM1380.47,115.2V88.4H1407.27V115.2H1380.47ZM1410.07,115.2V88.4H1436.47V115.2H1410.07ZM1380.47,144.8V118H1407.27V144.8H1380.47ZM1410.07,144.8V118H1436.47V144.8H1410.07ZM1380.47,174V147.6H1407.27V174H1380.47ZM1410.07,174V147.6H1436.47V174H1410.07ZM1410.07,203.6V176.8H1436.47V203.6H1410.07Z" />
<!-- A (second) -->
<path android:fillColor="#FFFFFF"
android:pathData="M1506.16,85.6V59.2H1532.56V85.6H1506.16ZM1535.36,85.6V59.2H1562.16V85.6H1535.36ZM1564.96,85.6V59.2H1591.76V85.6H1564.96ZM1594.56,85.6V59.2H1620.96V85.6H1594.56ZM1594.56,115.2V88.4H1620.96V115.2H1594.56ZM1623.76,115.2V88.4H1650.56V115.2H1623.76ZM1476.56,144.8V118H1503.36V144.8H1476.56ZM1506.16,144.8V118H1532.56V144.8H1506.16ZM1535.36,144.8V118H1562.16V144.8H1535.36ZM1564.96,144.8V118H1591.76V144.8H1564.96ZM1594.56,144.8V118H1620.96V144.8H1594.56ZM1623.76,144.8V118H1650.56V144.8H1623.76ZM1476.56,174V147.6H1503.36V174H1476.56ZM1506.16,174V147.6H1532.56V174H1506.16ZM1594.56,174V147.6H1620.96V174H1594.56ZM1623.76,174V147.6H1650.56V174H1623.76ZM1506.16,203.6V176.8H1532.56V203.6H1506.16ZM1535.36,203.6V176.8H1562.16V203.6H1535.36ZM1564.96,203.6V176.8H1591.76V203.6H1564.96ZM1594.56,203.6V176.8H1620.96V203.6H1594.56Z" />
<!-- G -->
<path android:fillColor="#FFFFFF"
android:pathData="M1720.22,85.6V59.2H1746.62V85.6H1720.22ZM1749.43,85.6V59.2H1776.22V85.6H1749.43ZM1779.03,85.6V59.2H1805.82V85.6H1779.03ZM1808.62,85.6V59.2H1835.03V85.6H1808.62ZM1690.62,115.2V88.4H1717.43V115.2H1690.62ZM1720.22,115.2V88.4H1746.62V115.2H1720.22ZM1808.62,115.2V88.4H1835.03V115.2H1808.62ZM1837.82,115.2V88.4H1864.62V115.2H1837.82ZM1690.62,144.8V118H1717.43V144.8H1690.62ZM1720.22,144.8V118H1746.62V144.8H1720.22ZM1808.62,144.8V118H1835.03V144.8H1808.62ZM1837.82,144.8V118H1864.62V144.8H1837.82ZM1690.62,174V147.6H1717.43V174H1690.62ZM1720.22,174V147.6H1746.62V174H1720.22ZM1808.62,174V147.6H1835.03V174H1808.62ZM1837.82,174V147.6H1864.62V174H1837.82ZM1720.22,203.6V176.8H1746.62V203.6H1720.22ZM1749.43,203.6V176.8H1776.22V203.6H1749.43ZM1779.03,203.6V176.8H1805.82V203.6H1779.03ZM1808.62,203.6V176.8H1835.03V203.6H1808.62ZM1837.82,203.6V176.8H1864.62V203.6H1837.82ZM1808.62,233.2V206.4H1835.03V233.2H1808.62ZM1837.82,233.2V206.4H1864.62V233.2H1837.82ZM1720.22,262.4V236H1746.62V262.4H1720.22ZM1749.43,262.4V236H1776.22V262.4H1749.43ZM1779.03,262.4V236H1805.82V262.4H1779.03ZM1808.62,262.4V236H1835.03V262.4H1808.62Z" />
<!-- O -->
<path android:fillColor="#FFFFFF"
android:pathData="M1934.29,85.6V59.2H1960.69V85.6H1934.29ZM1963.49,85.6V59.2H1990.29V85.6H1963.49ZM1993.09,85.6V59.2H2019.89V85.6H1993.09ZM2022.69,85.6V59.2H2049.09V85.6H2022.69ZM1904.69,115.2V88.4H1931.49V115.2H1904.69ZM1934.29,115.2V88.4H1960.69V115.2H1934.29ZM2022.69,115.2V88.4H2049.09V115.2H2022.69ZM2051.89,115.2V88.4H2078.69V115.2H2051.89ZM1904.69,144.8V118H1931.49V144.8H1904.69ZM1934.29,144.8V118H1960.69V144.8H1934.29ZM2022.69,144.8V118H2049.09V144.8H2022.69ZM2051.89,144.8V118H2078.69V144.8H2051.89ZM1904.69,174V147.6H1931.49V174H1904.69ZM1934.29,174V147.6H1960.69V174H1934.29ZM2022.69,174V147.6H2049.09V174H2022.69ZM2051.89,174V147.6H2078.69V174H2051.89ZM1963.49,203.6V176.8H1990.29V203.6H1963.49ZM1993.09,203.6V176.8H2019.89V203.6H1993.09ZM1934.29,203.6V176.8H1960.69V203.6H1934.29ZM2022.69,203.6V176.8H2049.09V203.6H2022.69Z" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M15,19l-7,-7 7,-7"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6,18L18,6M6,6l12,12"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9,5l7,7 -7,7"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M10,6H6a2,2 0,0 0,-2 2v10a2,2 0,0 0,2 2h10a2,2 0,0 0,2 -2v-4M14,4h6m0,0v6m0,-6L10,14"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4,4v6h6M20,20v-6h-6M5.64,15.36A8,8 0,0 0,18.36 18M18.36,8.64A8,8 0,0 0,5.64 6"
android:strokeColor="#FFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago pixel-art "A" for splash screen -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group
android:pivotX="512"
android:pivotY="512"
android:scaleX="0.55"
android:scaleY="0.55">
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
<color name="bitcoin_orange">#FFF7931A</color>
<color name="surface_dark">#FF0A0A0A</color>
<color name="surface_card">#FF1A1A1A</color>
<color name="splash_background">#FF000000</color>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Archipelago</string>
<string name="server_address_label">Server Address</string>
<string name="server_address_placeholder">192.168.1.100</string>
<string name="server_address_hint">Enter your Archipelago server IP or hostname</string>
<string name="connect">Connect</string>
<string name="connecting">Connecting…</string>
<string name="connection_failed">Could not reach server. Check the address and try again.</string>
<string name="connection_timeout">Connection timed out. Is the server running?</string>
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
<string name="get_started">Get Started</string>
<string name="use_https">Use HTTPS</string>
<string name="port_label">Port (optional)</string>
<string name="saved_servers">Saved Servers</string>
<string name="no_saved_servers">No saved servers yet</string>
<string name="remove_server">Remove</string>
<string name="disconnect">Disconnect</string>
<string name="server_unreachable">Server unreachable</string>
<string name="retry">Retry</string>
<string name="remote_input">Remote Control</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="open_in_browser">Open in browser</string>
<string name="back">Back</string>
<string name="forward">Forward</string>
<string name="refresh">Refresh</string>
<string name="server_name_label">Server Name (optional)</string>
<string name="server_name_placeholder">My Archipelago</string>
<string name="edit_server">Edit</string>
<string name="scan_node_qr">Scan Node\'s QR</string>
<string name="enter_manually">Enter Manually</string>
<string name="connect_landing_hint">Scan the pairing QR from your node\'s Companion popup, or enter the address manually</string>
<string name="scan_qr_hint">Point the camera at the pairing QR shown in the Companion popup</string>
<string name="camera_permission_needed">Camera access is needed to scan the pairing QR. You can also enter the server details manually.</string>
<string name="grant_camera_access">Grant Camera Access</string>
<string name="invalid_pairing_qr">Not an Archipelago pairing code</string>
<string name="update_app_for_qr">This pairing code needs a newer app version — please update the companion app</string>
<string name="add_server_qr">Add server by QR</string>
<string name="edit_server_title">Edit Server</string>
<string name="save_changes">Save Changes</string>
<string name="cancel">Cancel</string>
</resources>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Archipelago" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@color/black</item>
</style>
<style name="Theme.Archipelago.Splash" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splash_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_splash_logo</item>
<item name="postSplashScreenTheme">@style/Theme.Archipelago</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for local network Archipelago servers -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>