diff --git a/Android/.gitignore b/Android/.gitignore
new file mode 100644
index 00000000..9262b1ed
--- /dev/null
+++ b/Android/.gitignore
@@ -0,0 +1,16 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/build
+/app/release
+*.apk
+*.aab
+*.jks
+*.keystore
diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts
new file mode 100644
index 00000000..2cd5cb82
--- /dev/null
+++ b/Android/app/build.gradle.kts
@@ -0,0 +1,87 @@
+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 = 1
+ versionName = "0.1.0"
+
+ vectorDrawables {
+ useSupportLibrary = true
+ }
+ }
+
+ buildTypes {
+ 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")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
diff --git a/Android/app/proguard-rules.pro b/Android/app/proguard-rules.pro
new file mode 100644
index 00000000..158946a7
--- /dev/null
+++ b/Android/app/proguard-rules.pro
@@ -0,0 +1,7 @@
+# Keep WebView JavaScript interface
+-keepclassmembers class com.archipelago.app.ui.screens.WebViewScreen$* {
+ public *;
+}
+
+# Keep Compose
+-dontwarn androidx.compose.**
diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..8759972a
--- /dev/null
+++ b/Android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/app/src/main/assets/connect.html b/Android/app/src/main/assets/connect.html
new file mode 100644
index 00000000..d4db1f8f
--- /dev/null
+++ b/Android/app/src/main/assets/connect.html
@@ -0,0 +1,492 @@
+
+
+
+
+
+Archipelago
+
+
+
+
+
+
+
+
+
+
Archipelago
+
Your Sovereign
Personal Server
+
Bitcoin node, app platform, and private cloud — all in one box you control.
+
+
+
+
+
+
+
+
+
Connect to Server
+
Enter your Archipelago server IP or hostname
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt
new file mode 100644
index 00000000..ed001ab1
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt
@@ -0,0 +1,5 @@
+package com.archipelago.app
+
+import android.app.Application
+
+class ArchipelagoApp : Application()
diff --git a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt
new file mode 100644
index 00000000..e268d489
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt
@@ -0,0 +1,22 @@
+package com.archipelago.app
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import com.archipelago.app.ui.navigation.AppNavHost
+import com.archipelago.app.ui.theme.ArchipelagoTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ installSplashScreen()
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ setContent {
+ ArchipelagoTheme {
+ AppNavHost()
+ }
+ }
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt
new file mode 100644
index 00000000..691629c3
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt
@@ -0,0 +1,104 @@
+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 by preferencesDataStore(name = "server_prefs")
+
+data class ServerEntry(
+ val address: String,
+ val useHttps: Boolean,
+ val port: String = "",
+) {
+ fun toUrl(): String {
+ val scheme = if (useHttps) "https" else "http"
+ val portSuffix = if (port.isNotBlank()) ":$port" else ""
+ return "$scheme://$address$portSuffix"
+ }
+
+ fun serialize(): String = "$address|$useHttps|$port"
+
+ 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) { "" },
+ )
+ }
+ }
+}
+
+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 savedServersKey = stringSetPreferencesKey("saved_servers")
+ private val introSeenKey = booleanPreferencesKey("intro_seen")
+
+ val activeServer: Flow = context.dataStore.data.map { prefs ->
+ val address = prefs[activeAddressKey] ?: return@map null
+ ServerEntry(
+ address = address,
+ useHttps = prefs[activeHttpsKey] ?: false,
+ port = prefs[activePortKey] ?: "",
+ )
+ }
+
+ val savedServers: Flow> = context.dataStore.data.map { prefs ->
+ val raw = prefs[savedServersKey] ?: emptySet()
+ raw.mapNotNull { ServerEntry.deserialize(it) }
+ }
+
+ val introSeen: Flow = 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
+ }
+ addSavedServer(server)
+ }
+
+ suspend fun clearActiveServer() {
+ context.dataStore.edit { prefs ->
+ prefs.remove(activeAddressKey)
+ prefs.remove(activeHttpsKey)
+ prefs.remove(activePortKey)
+ }
+ }
+
+ suspend fun addSavedServer(server: ServerEntry) {
+ context.dataStore.edit { prefs ->
+ val current = prefs[savedServersKey] ?: emptySet()
+ prefs[savedServersKey] = current + server.serialize()
+ }
+ }
+
+ suspend fun removeSavedServer(server: ServerEntry) {
+ context.dataStore.edit { prefs ->
+ val current = prefs[savedServersKey] ?: emptySet()
+ prefs[savedServersKey] = current - server.serialize()
+ }
+ }
+
+ suspend fun markIntroSeen() {
+ context.dataStore.edit { prefs ->
+ prefs[introSeenKey] = true
+ }
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt
new file mode 100644
index 00000000..e6e736a4
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt
@@ -0,0 +1,96 @@
+package com.archipelago.app.ui.navigation
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+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.ServerPreferences
+import com.archipelago.app.ui.screens.IntroScreen
+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"
+}
+
+@Composable
+fun AppNavHost() {
+ 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)
+
+ // Wait for preferences to load before deciding
+ if (introSeen == null) return
+
+ 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 }
+ }
+ },
+ )
+ }
+
+ composable(Routes.WEB_VIEW) {
+ val server = activeServer
+ if (server == null) {
+ // Server was cleared, go back to connect
+ ServerConnectScreen(
+ onConnected = { _ ->
+ navController.navigate(Routes.WEB_VIEW) {
+ popUpTo(0) { inclusive = true }
+ }
+ },
+ )
+ } else {
+ WebViewScreen(
+ serverUrl = server.toUrl(),
+ onDisconnect = {
+ scope.launch {
+ prefs.clearActiveServer()
+ navController.navigate(Routes.SERVER_CONNECT) {
+ popUpTo(0) { inclusive = true }
+ }
+ }
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
new file mode 100644
index 00000000..56a860c3
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt
@@ -0,0 +1,223 @@
+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.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.graphics.ColorFilter
+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)
+ .windowInsetsPadding(WindowInsets.safeDrawing),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ // Wide pixel-art logo
+ Image(
+ painter = painterResource(id = R.drawable.ic_logo_wide),
+ contentDescription = "Archipelago",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ .alpha(logoAlpha.value),
+ colorFilter = ColorFilter.tint(Color.White),
+ )
+
+ 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 = TextPrimary,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.welcome_subtitle),
+ style = MaterialTheme.typography.bodyLarge,
+ color = TextMuted,
+ 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,
+ )
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
new file mode 100644
index 00000000..66d7712b
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt
@@ -0,0 +1,426 @@
+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.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+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.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.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.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.data.ServerPreferences
+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) {
+ val context = LocalContext.current
+ val prefs = remember { ServerPreferences(context) }
+ val scope = rememberCoroutineScope()
+ val keyboard = LocalSoftwareKeyboardController.current
+
+ var address by remember { mutableStateOf("") }
+ var port by remember { mutableStateOf("") }
+ var useHttps by remember { mutableStateOf(false) }
+ var isConnecting by remember { mutableStateOf(false) }
+ var errorMessage by remember { mutableStateOf(null) }
+
+ val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
+
+ 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)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(SurfaceBlack)
+ .windowInsetsPadding(WindowInsets.safeDrawing),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(state = rememberScrollState())
+ .drawWithContent { drawContent() }
+ .padding(horizontal = 24.dp)
+ .padding(top = 48.dp, bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ // Wide logo
+ Image(
+ painter = painterResource(id = R.drawable.ic_logo_wide),
+ contentDescription = "Archipelago",
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colorFilter = ColorFilter.tint(Color.White),
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = "Connect to Server",
+ style = MaterialTheme.typography.headlineMedium,
+ color = TextPrimary,
+ textAlign = TextAlign.Center,
+ )
+
+ Text(
+ text = stringResource(R.string.server_address_hint),
+ style = MaterialTheme.typography.bodyMedium,
+ color = TextMuted,
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // Glass card with form
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .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 = 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.Go,
+ ),
+ keyboardActions = KeyboardActions(
+ onGo = {
+ keyboard?.hide()
+ connect(ServerEntry(address, useHttps, port))
+ },
+ ),
+ 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 = port,
+ onValueChange = {
+ port = it.filter { c -> c.isDigit() }.take(5)
+ errorMessage = null
+ },
+ label = { Text(stringResource(R.string.port_label)) },
+ placeholder = { Text("80") },
+ modifier = Modifier.width(140.dp),
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Number,
+ imeAction = ImeAction.Go,
+ ),
+ keyboardActions = KeyboardActions(
+ onGo = {
+ keyboard?.hide()
+ connect(ServerEntry(address, useHttps, port))
+ },
+ ),
+ 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)
+ }
+ }
+
+ // Connect button — glass style
+ GlassButton(
+ text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
+ onClick = {
+ keyboard?.hide()
+ connect(ServerEntry(address, useHttps, port))
+ },
+ modifier = Modifier.fillMaxWidth().height(56.dp),
+ )
+
+ if (isConnecting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = Color.White.copy(alpha = 0.6f),
+ strokeWidth = 2.dp,
+ )
+ }
+
+ // Saved servers
+ if (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) },
+ onRemove = { scope.launch { prefs.removeSavedServer(it) } },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SavedServerItem(
+ server: ServerEntry,
+ onConnect: (ServerEntry) -> Unit,
+ onRemove: (ServerEntry) -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .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) {
+ 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.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary)
+ if (server.port.isNotBlank()) {
+ Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = 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(object : X509TrustManager {
+ override fun checkClientTrusted(chain: Array?, authType: String?) {}
+ override fun checkServerTrusted(chain: Array?, authType: String?) {}
+ override fun getAcceptedIssuers(): Array = 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
+ }
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
new file mode 100644
index 00000000..1584faba
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
@@ -0,0 +1,281 @@
+package com.archipelago.app.ui.screens
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+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.background
+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.material.icons.Icons
+import androidx.compose.material.icons.filled.CloudOff
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+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.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+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
+
+@SuppressLint("SetJavaScriptEnabled")
+@Composable
+fun WebViewScreen(
+ serverUrl: String,
+ onDisconnect: () -> Unit,
+) {
+ var isLoading by remember { mutableStateOf(true) }
+ var loadProgress by remember { mutableIntStateOf(0) }
+ var hasError by remember { mutableStateOf(false) }
+ var webView by remember { mutableStateOf(null) }
+
+ BackHandler(enabled = 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)
+
+ 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
+ setSupportMultipleWindows(true) // enables onCreateWindow for window.open
+ }
+
+ 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,
+ )
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ request: WebResourceRequest?,
+ error: WebResourceError?,
+ ) {
+ if (request?.isForMainFrame == true) {
+ hasError = true
+ isLoading = false
+ }
+ }
+
+ override fun shouldOverrideUrlLoading(
+ view: WebView?,
+ request: WebResourceRequest?,
+ ): Boolean {
+ val url = request?.url?.toString() ?: return false
+ // Keep navigation within the Archipelago server
+ if (url.startsWith(serverUrl)) return false
+ // Open external URLs in the system browser
+ try {
+ val intent = android.content.Intent(
+ android.content.Intent.ACTION_VIEW,
+ android.net.Uri.parse(url),
+ )
+ context.startActivity(intent)
+ } catch (_: Exception) {}
+ return true
+ }
+ }
+
+ webChromeClient = object : WebChromeClient() {
+ override fun onProgressChanged(view: WebView?, newProgress: Int) {
+ loadProgress = newProgress
+ }
+
+ // Handle window.open() — open in system browser
+ override fun onCreateWindow(
+ view: WebView?,
+ isDialog: Boolean,
+ isUserGesture: Boolean,
+ resultMsg: android.os.Message?,
+ ): Boolean {
+ // Extract the URL from the hit test
+ val data = view?.hitTestResult?.extra
+ if (data != null) {
+ try {
+ val intent = android.content.Intent(
+ android.content.Intent.ACTION_VIEW,
+ android.net.Uri.parse(data),
+ )
+ context.startActivity(intent)
+ } catch (_: Exception) {}
+ }
+ return false
+ }
+ }
+
+ 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,
+ )
+ }
+ }
+ }
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt
new file mode 100644
index 00000000..6e2ffef2
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Color.kt
@@ -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)
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt
new file mode 100644
index 00000000..84a52335
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Theme.kt
@@ -0,0 +1,38 @@
+package com.archipelago.app.ui.theme
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+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,
+)
+
+@Composable
+fun ArchipelagoTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = DarkColorScheme,
+ typography = Typography,
+ content = content,
+ )
+}
diff --git a/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt b/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt
new file mode 100644
index 00000000..c9444834
--- /dev/null
+++ b/Android/app/src/main/java/com/archipelago/app/ui/theme/Type.kt
@@ -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,
+ ),
+)
diff --git a/Android/app/src/main/res/drawable/ic_launcher_background.xml b/Android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 00000000..2fc77436
--- /dev/null
+++ b/Android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/Android/app/src/main/res/drawable/ic_launcher_foreground.xml b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 00000000..f631630b
--- /dev/null
+++ b/Android/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/app/src/main/res/drawable/ic_logo_wide.xml b/Android/app/src/main/res/drawable/ic_logo_wide.xml
new file mode 100644
index 00000000..51122311
--- /dev/null
+++ b/Android/app/src/main/res/drawable/ic_logo_wide.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/app/src/main/res/drawable/ic_splash_logo.xml b/Android/app/src/main/res/drawable/ic_splash_logo.xml
new file mode 100644
index 00000000..31eb8233
--- /dev/null
+++ b/Android/app/src/main/res/drawable/ic_splash_logo.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 00000000..6b78462d
--- /dev/null
+++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 00000000..6b78462d
--- /dev/null
+++ b/Android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/Android/app/src/main/res/values/colors.xml b/Android/app/src/main/res/values/colors.xml
new file mode 100644
index 00000000..f3f45238
--- /dev/null
+++ b/Android/app/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #FF000000
+ #FFFFFFFF
+ #FFF7931A
+ #FF0A0A0A
+ #FF1A1A1A
+ #FF000000
+
diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..0d71030b
--- /dev/null
+++ b/Android/app/src/main/res/values/strings.xml
@@ -0,0 +1,22 @@
+
+
+ Archipelago
+ Server Address
+ 192.168.1.100
+ Enter your Archipelago server IP or hostname
+ Connect
+ Connecting…
+ Could not reach server. Check the address and try again.
+ Connection timed out. Is the server running?
+ Your Sovereign\nPersonal Server
+ Bitcoin node, app platform, and private cloud — all in one box you control.
+ Get Started
+ Use HTTPS
+ Port (optional)
+ Saved Servers
+ No saved servers yet
+ Remove
+ Disconnect
+ Server unreachable
+ Retry
+
diff --git a/Android/app/src/main/res/values/themes.xml b/Android/app/src/main/res/values/themes.xml
new file mode 100644
index 00000000..3da59fcb
--- /dev/null
+++ b/Android/app/src/main/res/values/themes.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/Android/app/src/main/res/xml/network_security_config.xml b/Android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 00000000..cdf19ccc
--- /dev/null
+++ b/Android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/Android/build.gradle.kts b/Android/build.gradle.kts
new file mode 100644
index 00000000..ac5880ab
--- /dev/null
+++ b/Android/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "8.4.0" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+}
diff --git a/Android/gradle.properties b/Android/gradle.properties
new file mode 100644
index 00000000..8679d5b5
--- /dev/null
+++ b/Android/gradle.properties
@@ -0,0 +1,5 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
+android.suppressUnsupportedCompileSdk=35
diff --git a/Android/gradle/wrapper/gradle-wrapper.jar b/Android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 00000000..e6441136
Binary files /dev/null and b/Android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/Android/gradle/wrapper/gradle-wrapper.properties b/Android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..b82aa23a
--- /dev/null
+++ b/Android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/Android/gradlew b/Android/gradlew
new file mode 100755
index 00000000..1aa94a42
--- /dev/null
+++ b/Android/gradlew
@@ -0,0 +1,249 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/Android/gradlew.bat b/Android/gradlew.bat
new file mode 100644
index 00000000..7101f8e4
--- /dev/null
+++ b/Android/gradlew.bat
@@ -0,0 +1,92 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/Android/settings.gradle.kts b/Android/settings.gradle.kts
new file mode 100644
index 00000000..06cab823
--- /dev/null
+++ b/Android/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Archipelago"
+include(":app")