Add native Android Ocho with shared Go engine and passkey association
34 files changed,
+917
−73
.gitignore+5 −0AGENTS.md+4 −0README.md+4 −4android/README.md+28 −0android/app/build.gradle.kts+26 −0android/app/src/androidTest/java/chat/ocho/app/NativeChatTest.kt+55 −0android/app/src/main/AndroidManifest.xml+10 −0android/app/src/main/java/chat/ocho/app/AccountDialogs.kt+54 −0android/app/src/main/java/chat/ocho/app/AccountModel.kt+73 −0android/app/src/main/java/chat/ocho/app/MainActivity.kt+51 −0android/app/src/main/java/chat/ocho/app/OchoScreen.kt+88 −0android/app/src/main/java/chat/ocho/app/Passkeys.kt+55 −0android/app/src/main/java/chat/ocho/app/SecureStore.kt+38 −0android/app/src/main/res/drawable/ocho_icon.pngbinaryandroid/app/src/main/res/values/styles.xml+1 −0android/build.gradle.kts+5 −0android/gradle.properties+3 −0android/gradle/wrapper/gradle-wrapper.jarbinaryandroid/gradle/wrapper/gradle-wrapper.properties+8 −0android/gradlew+252 −0android/settings.gradle.kts+5 −0apple/Infinity/WelcomeView.swift+21 −0apple/README.md+19 −9apple/project.yml+2 −2cmd/infinity-home/main.go+1 −1docs/infinity/android-handoff.md+8 −2docs/infinity/native-passkeys.md+3 −3docs/infinity/status.md+20 −45internal/infinityhome/android.go+42 −0internal/infinityhome/home.go+2 −0internal/infinityhome/passkeys.go+2 −2internal/infinityhome/passkeys_test.go+6 −1test/infinity/home-browser.mjs+6 −4tools/infinity/check-android+20 −0
Intent
diff --git a/.gitignore b/.gitignore| index 38417b5..d959895 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -51,3 +51,8 @@ apple/build-*/ |
| apple/OchoCore/.swiftpm/ |
| # Rebuilt reproducibly from the shared Go engine; never commit binary frameworks. |
| apple/OchoCore/Artifacts/ |
| +# Android local builds and machine SDK configuration. |
| +android/.gradle/ |
| +android/**/build/ |
| +android/local.properties |
| +android/.kotlin/ |
diff --git a/AGENTS.md b/AGENTS.md| index 392c149..5b72915 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -9,6 +9,10 @@ Paul: fork Eleven Messenger; call it β; connect by link without phone numbers; |
| Implementation assumptions (Codex, pending product feedback): web plus isolated iOS/macOS clients; Carlos hosted by default with self-hosting supported; recoverable client-held identity and multiple devices; signed, consent-gated cross-instance delivery. These are assumptions, not attributed approvals. |
| +## Updated direction β 2026-09-06 |
| + |
| +Paul: the display name is **Ocho**; the infinity glyph is the icon. Apple and Android apps must be fully native, with no WebViews. Share the native engine via Go/gomobile; keep SwiftUI and Kotlin/Compose presentation and platform secure storage/passkeys. PRF passkeys are an alternative to recovery files; signed-in web chat stays at ocho.chat. These supersede earlier wrapper assumptions. |
| + |
| ## Architecture and process |
| - Apply `.agents/skills/building-carlos-apps/SKILL.md`. Its vendor-neutral intent applies; identify actual AI authorship as Codex, not Claude. |
diff --git a/README.md b/README.md| index 61410ce..f5b76cc 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -1,8 +1,8 @@ |
| -# π€ β |
| +# π€ Ocho |
| A personal, end-to-end encrypted messenger. Share a link, connect, chat. No phone number. One private instance per person, built on Carlos. DMs and group chats in one list; voice and video come later. |
| -Text DMs, consent-gated groups, encrypted history, delivery retries, recovery files and Apple app shells are implemented. Local browser and protocol checks pass. Hosted startup verification is still blocked by Carloku storage/release-adoption problems; this is not a public-launch claim. See [current status](docs/infinity/status.md) and [DESIGN.md](DESIGN.md). |
| +Text DMs, consent-gated groups, encrypted history, delivery retries, recovery files and PRF passkey recovery are implemented. Native SwiftUI and Kotlin/Compose apps share a Go/gomobile engine. The web client stays at ocho.chat, backed by separate personal instances. Local and hosted browser checks pass; physical passkey provider verification and store distribution remain outstanding. See [current status](docs/infinity/status.md) and [DESIGN.md](DESIGN.md). |
| ## Build and check |
| @@ -15,7 +15,7 @@ tools/infinity/check all |
| CGO_ENABLED=0 go build -o .build/infinity ./cmd/infinity |
| ``` |
| -`check all` runs the server, crypto, protocol, registration and three-instance browser tests. [Apple build instructions](apple/README.md) cover iOS and macOS. The inherited Eleven entry point is retained for provenance; `cmd/infinity` is this app's runtime. |
| +`check all` runs the server, crypto, protocol, registration and three-instance browser tests. [Apple build instructions](apple/README.md) cover iOS and macOS; [Android instructions](android/README.md) cover Kotlin/Compose and the shared engine. The inherited Eleven entry point is retained for provenance; `cmd/infinity` is this app's runtime. |
| ## Run your own instance |
| @@ -27,7 +27,7 @@ Every person needs a separate process, database and HTTPS origin. The shared reg |
| ## Hosted review |
| -`tools/infinity/deploy personal` and `tools/infinity/deploy home` build both Linux architectures and deploy to this fork's dedicated Carlos canaries. They require a clean committed checkout and an authenticated Carlos CLI. Provisioning credentials are sealed platform secrets and are not committed. Current endpoints, DNS requirements and unresolved platform failures are recorded in [status](docs/infinity/status.md). |
| +`tools/infinity/deploy personal` and `tools/infinity/deploy home` build both Linux architectures and deploy to this fork's dedicated Carlos canaries. They require a clean committed checkout and an authenticated Carlos CLI. Provisioning credentials are sealed platform secrets and are not committed. Current endpoints, deployment evidence and remaining verification are recorded in [status](docs/infinity/status.md). |
| ## Honest trade-offs |
diff --git a/android/README.md b/android/README.md| new file mode 100644 |
| index 0000000..f5175ca |
| --- /dev/null |
| +++ b/android/README.md |
| @@ -0,0 +1,28 @@ |
| +# π€ Ocho for Android |
| + |
| +Native Kotlin/Compose UI over the same Go/gomobile engine used by SwiftUI. No WebViews. Android Keystore encrypts the saved identity; platform file/share dialogs handle recovery and invitations. Credential Manager provides passkey ceremonies for `ocho.chat`; Go handles PRF wrapping and all chat cryptography. |
| + |
| +## Build |
| + |
| +Use JDK 21 and an Android SDK with API 35, build-tools 35.0.0 and NDK 28.2.13676358. Set `JAVA_HOME` and `ANDROID_HOME` to those installations. Gradle and x/mobile versions are pinned. |
| + |
| +```sh |
| +tools/infinity/build-mobile android |
| +android/gradlew -p android assembleDebug |
| +``` |
| + |
| +APK: `android/app/build/outputs/apk/debug/app-debug.apk`. Package: `chat.ocho.app`; minimum API 26. The debug build uses the local Android debug signing key; it is not a store release. |
| + |
| +## Verify on a dedicated emulator |
| + |
| +```sh |
| +ANDROID_SERIAL=emulator-5562 tools/infinity/check-android |
| +``` |
| + |
| +This starts two disposable personal processes, reverses only their ports, installs the test build on the selected emulator and verifies actual Compose receipt/send, peer decryption, Keystore persistence and activity recreation with restored history. Never select another session's emulator or a person's phone for this test. |
| + |
| +## Passkeys |
| + |
| +The home server must publish `/.well-known/assetlinks.json` for `chat.ocho.app` with the exact installed APK signing fingerprint, configured through `INFINITY_ANDROID_CERT_SHA256`. It also pins that Android assertion origin. Update the association for the actual distribution signing certificate before release. Provider PRF support is required to unlock E2EE keys; otherwise use a recovery file. Physical-provider Android passkey interoperability still needs a user-present device test. |
| + |
| +See `docs/infinity/android-handoff.md` for this machine's SDK paths and coordination notes. |
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts| new file mode 100644 |
| index 0000000..3ba94c8 |
| --- /dev/null |
| +++ b/android/app/build.gradle.kts |
| @@ -0,0 +1,26 @@ |
| +plugins { id("com.android.application"); id("org.jetbrains.kotlin.android"); id("org.jetbrains.kotlin.plugin.compose") } |
| +android { |
| + namespace = "chat.ocho.app" |
| + compileSdk = 35 |
| + defaultConfig { applicationId = "chat.ocho.app"; minSdk = 26; targetSdk = 35; versionCode = 1; versionName = "0.2.0"; testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } |
| + buildFeatures { compose = true; buildConfig = true } |
| + compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 } |
| + kotlinOptions { jvmTarget = "17" } |
| +} |
| +dependencies { |
| + implementation(files("../../.build/mobile/ocho-engine.aar")) |
| + implementation(platform("androidx.compose:compose-bom:2025.04.01")) |
| + implementation("androidx.activity:activity-compose:1.10.1") |
| + implementation("androidx.compose.material3:material3") |
| + implementation("androidx.compose.ui:ui-tooling-preview") |
| + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0") |
| + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.0") |
| + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2") |
| + implementation("androidx.credentials:credentials:1.5.0") |
| + implementation("androidx.credentials:credentials-play-services-auth:1.5.0") |
| + debugImplementation("androidx.compose.ui:ui-tooling") |
| + androidTestImplementation(platform("androidx.compose:compose-bom:2025.04.01")) |
| + androidTestImplementation("androidx.compose.ui:ui-test-junit4") |
| + androidTestImplementation("androidx.test:runner:1.6.2") |
| + androidTestImplementation("androidx.test.ext:junit:1.2.1") |
| +} |
diff --git a/android/app/src/androidTest/java/chat/ocho/app/NativeChatTest.kt b/android/app/src/androidTest/java/chat/ocho/app/NativeChatTest.kt| new file mode 100644 |
| index 0000000..c39fe5f |
| --- /dev/null |
| +++ b/android/app/src/androidTest/java/chat/ocho/app/NativeChatTest.kt |
| @@ -0,0 +1,55 @@ |
| +// π€ Actual Compose app, Keystore and Go bindings against two disposable personal processes. |
| +package chat.ocho.app |
| + |
| +import android.content.Intent |
| +import android.view.View |
| +import android.view.ViewGroup |
| +import android.webkit.WebView |
| +import androidx.compose.ui.test.* |
| +import androidx.compose.ui.test.junit4.createEmptyComposeRule |
| +import androidx.test.core.app.ActivityScenario |
| +import androidx.test.platform.app.InstrumentationRegistry |
| +import chat.ocho.engine.ocho.Ocho |
| +import org.json.JSONObject |
| +import org.junit.Assert.* |
| +import org.junit.Rule |
| +import org.junit.Test |
| + |
| +class NativeChatTest { |
| + @get:Rule val compose = createEmptyComposeRule() |
| + @Test fun encryptedConversationSurvivesNativeRestart() { |
| + val context = InstrumentationRegistry.getInstrumentation().targetContext |
| + go.Seq.setContext(context) |
| + val identity = Ocho.newIdentity("http://localhost:18881", "Android Alice") |
| + val alice = Ocho.newClient(identity) |
| + val bob = Ocho.newClient(Ocho.newIdentity("http://localhost:18882", "Android Bob")) |
| + val claim = "a".repeat(43) |
| + alice.setup(claim); bob.setup(claim) |
| + val thread = bob.connect(alice.invite()) |
| + alice.accept(JSONObject(alice.refresh()).getJSONArray("requests").getJSONObject(0).getString("thread")) |
| + bob.message(thread, "Hello from the other encrypted client") |
| + val secure = SecureStore(context) |
| + secure.write("owner", identity) |
| + assertEquals(identity, secure.read("owner")) |
| + val stored = context.getSharedPreferences("ocho.private", 0).getString("owner", "")!! |
| + assertFalse(stored.contains("privateEnc")) |
| + try { |
| + ActivityScenario.launch<MainActivity>(Intent(context, MainActivity::class.java)).use { scenario -> |
| + compose.waitUntil(20000) { compose.onAllNodesWithText("Android Bob").fetchSemanticsNodes().isNotEmpty() } |
| + compose.onNodeWithText("Android Bob").performClick() |
| + compose.onNodeWithText("Hello from the other encrypted client").assertIsDisplayed() |
| + compose.onNodeWithText("Message").performTextInput("Sent from the native Android composer") |
| + compose.onNodeWithText("Send").performClick() |
| + compose.waitUntil(20000) { compose.onAllNodesWithText("Sent from the native Android composer").fetchSemanticsNodes().isNotEmpty() } |
| + val received = JSONObject(bob.refresh()).getJSONArray("threads").getJSONObject(0).getJSONArray("messages") |
| + assertEquals("Sent from the native Android composer", received.getJSONObject(received.length() - 1).getString("text")) |
| + scenario.onActivity { assertFalse(containsWebView(it.window.decorView)) } |
| + scenario.recreate() |
| + compose.waitUntil(20000) { compose.onAllNodesWithText("Android Bob").fetchSemanticsNodes().isNotEmpty() } |
| + compose.onNodeWithText("Android Bob").performClick() |
| + compose.onNodeWithText("Sent from the native Android composer").assertIsDisplayed() |
| + } |
| + } finally { secure.remove("owner") } |
| + } |
| + private fun containsWebView(view: View): Boolean = view is WebView || (view is ViewGroup && (0 until view.childCount).any { containsWebView(view.getChildAt(it)) }) |
| +} |
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml| new file mode 100644 |
| index 0000000..54c8ad8 |
| --- /dev/null |
| +++ b/android/app/src/main/AndroidManifest.xml |
| @@ -0,0 +1,10 @@ |
| +<!-- π€ Platform-native Ocho; private identity backup is explicit through passkeys/recovery. --> |
| +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
| + <uses-permission android:name="android.permission.INTERNET" /> |
| + <application android:label="Ocho" android:icon="@drawable/ocho_icon" android:allowBackup="false" android:theme="@style/OchoTheme"> |
| + <activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:windowSoftInputMode="adjustResize"> |
| + <intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter> |
| + <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="ocho"/></intent-filter> |
| + </activity> |
| + </application> |
| +</manifest> |
diff --git a/android/app/src/main/java/chat/ocho/app/AccountDialogs.kt b/android/app/src/main/java/chat/ocho/app/AccountDialogs.kt| new file mode 100644 |
| index 0000000..a4037a6 |
| --- /dev/null |
| +++ b/android/app/src/main/java/chat/ocho/app/AccountDialogs.kt |
| @@ -0,0 +1,54 @@ |
| +// π€ Native onboarding, connection and recovery controls. |
| +package chat.ocho.app |
| +import androidx.compose.foundation.layout.* |
| +import androidx.compose.foundation.rememberScrollState |
| +import androidx.compose.foundation.verticalScroll |
| +import androidx.compose.material3.* |
| +import androidx.compose.runtime.* |
| +import androidx.compose.ui.Modifier |
| +import androidx.compose.ui.text.input.PasswordVisualTransformation |
| +import androidx.compose.ui.unit.dp |
| +import androidx.compose.ui.unit.sp |
| +import kotlinx.coroutines.launch |
| +import org.json.JSONObject |
| + |
| +@Composable fun WelcomeScreen(ui: AccountUI, busy: Boolean, recovery: String?, onRecovery: () -> Unit, onPasskey: () -> Unit, onCreate: (String) -> Unit, onRestore: (String, String) -> Unit) { |
| + var name by remember { mutableStateOf("") }; var password by remember { mutableStateOf("") } |
| + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(28.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) { |
| + Text("β", fontSize = 72.sp, color = MaterialTheme.colorScheme.primary) |
| + Text(if (ui.stage != null) "To infinity.\nAnd your people." else "Just you.\nAnd your people.", style = MaterialTheme.typography.headlineLarge) |
| + if (ui.stage != null) { LinearProgressIndicator(Modifier.fillMaxWidth()); Text(ui.stage); Text("A little space of your own. A lot of possibilities.") } |
| + else { |
| + Text("Private conversations, without a phone number.") |
| + Button(enabled = !busy, onClick = onPasskey) { Text("Sign in with a passkey") } |
| + OutlinedTextField(value = name, onValueChange = { if (it.length <= 80) name = it }, label = { Text("Your name") }, singleLine = true) |
| + Button(enabled = !busy && name.isNotBlank(), onClick = { onCreate(name) }) { Text("Create your account") } |
| + TextButton(enabled = !busy, onClick = onRecovery) { Text("I have a recovery file") } |
| + if (recovery != null) { OutlinedTextField(value = password, onValueChange = { password = it }, label = { Text("Recovery password") }, visualTransformation = PasswordVisualTransformation()); Button(enabled = !busy, onClick = { onRestore(recovery, password) }) { Text("Open your account") } } |
| + Text("Your messages are encrypted on your device.", style = MaterialTheme.typography.bodySmall) |
| + } |
| + } |
| +} |
| +@Composable fun AccountDialogs(kind: String, model: AccountModel, ui: AccountUI, thread: JSONObject?, incoming: String, onPasskey: (Boolean) -> Unit, onSave: (String) -> Unit, dismiss: () -> Unit) { |
| + val scope = rememberCoroutineScope() |
| + var text by remember(kind) { mutableStateOf(if (kind == "connect") incoming else "") } |
| + var selected by remember { mutableStateOf(emptySet<String>()) } |
| + AlertDialog(onDismissRequest = dismiss, title = { Text(when (kind) { "connect" -> "Connect with someone"; "group" -> "New group"; "details" -> "People"; else -> "Recovery & another device" }) }, text = { |
| + Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { |
| + if (ui.error != null) Text(ui.error, style = MaterialTheme.typography.bodySmall) |
| + when (kind) { |
| + "connect" -> OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Invitation link") }, maxLines = 5) |
| + "group" -> { OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Group name") }); ui.threads.filter { !it.getBoolean("group") && !it.getBoolean("pending") }.forEach { contact -> Row { Checkbox(checked = selected.contains(contact.getString("id")), onCheckedChange = { selected = if (it) selected + contact.getString("id") else selected - contact.getString("id") }); Text(contact.getString("name")) } } } |
| + "details" -> thread?.getJSONArray("members")?.objects()?.forEach { person -> Row { Text(person.getString("name"), Modifier.weight(1f)); if (thread.optString("owner") == ui.identity?.getString("signing") && person.getString("signing") != ui.identity.getString("signing")) TextButton(enabled = !ui.busy, onClick = { scope.launch { model.remove(thread.getString("id"), person.getString("signing")) } }) { Text("Remove") } } } |
| + else -> { Button(enabled = !ui.busy, onClick = { onPasskey(true) }) { Text("Add a passkey") }; Text("Use the same passkey to open Ocho and your messages on your other devices."); OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Recovery password (12+ characters)") }, visualTransformation = PasswordVisualTransformation()) } |
| + } |
| + } |
| + }, confirmButton = { |
| + when (kind) { |
| + "connect" -> TextButton(enabled = !ui.busy && text.isNotBlank(), onClick = { scope.launch { if (model.connect(text)) dismiss() } }) { Text("Connect") } |
| + "group" -> TextButton(enabled = !ui.busy && text.isNotBlank() && selected.size >= 2, onClick = { scope.launch { if (model.group(text, selected)) dismiss() } }) { Text("Create group") } |
| + "details" -> TextButton(onClick = dismiss) { Text("Done") } |
| + else -> TextButton(enabled = !ui.busy && text.length >= 12, onClick = { scope.launch { model.recovery(text, onSave) } }) { Text("Save recovery file") } |
| + } |
| + }, dismissButton = { TextButton(onClick = dismiss) { Text("Close") } }) |
| +} |
diff --git a/android/app/src/main/java/chat/ocho/app/AccountModel.kt b/android/app/src/main/java/chat/ocho/app/AccountModel.kt| new file mode 100644 |
| index 0000000..a330c1b |
| --- /dev/null |
| +++ b/android/app/src/main/java/chat/ocho/app/AccountModel.kt |
| @@ -0,0 +1,73 @@ |
| +// π€ Kotlin owns presentation and secure storage. The Go engine owns all chat operations. |
| +package chat.ocho.app |
| +import android.app.Application |
| +import androidx.lifecycle.AndroidViewModel |
| +import androidx.lifecycle.viewModelScope |
| +import chat.ocho.engine.ocho.Ocho |
| +import chat.ocho.engine.ocho.Client |
| +import chat.ocho.engine.ocho.Progress |
| +import kotlinx.coroutines.* |
| +import kotlinx.coroutines.flow.MutableStateFlow |
| +import kotlinx.coroutines.sync.Mutex |
| +import kotlinx.coroutines.sync.withLock |
| +import org.json.JSONArray |
| +import org.json.JSONObject |
| + |
| +data class AccountUI(val identity: JSONObject? = null, val threads: List<JSONObject> = emptyList(), val requests: List<JSONObject> = emptyList(), val busy: Boolean = false, val stage: String? = null, val error: String? = null) |
| +fun JSONArray.objects() = (0 until length()).map { getJSONObject(it) } |
| +class AccountModel(application: Application): AndroidViewModel(application) { |
| + companion object { const val HOME = "https://ocho.chat" } |
| + val ui = MutableStateFlow(AccountUI()) |
| + private val secure = SecureStore(application) |
| + private val mutex = Mutex() |
| + private var client: Client? = null |
| + init { |
| + viewModelScope.launch { run { |
| + secure.read("owner")?.let { identity -> Ocho.validateIdentity(identity); client = Ocho.newClient(identity); ui.value = ui.value.copy(identity = JSONObject(identity)); refresh() } |
| + } } |
| + viewModelScope.launch { while (isActive) { delay(3000); if (client != null && !ui.value.busy) run(clearError = false) { refresh() } } } |
| + } |
| + suspend fun run(clearError: Boolean = true, operation: suspend () -> Unit): Boolean { |
| + return mutex.withLock { |
| + ui.value = ui.value.copy(busy = true, error = if (clearError) null else ui.value.error) |
| + try { withContext(Dispatchers.IO) { operation() }; true } |
| + catch (e: Exception) { if (e is CancellationException) throw e; ui.value = ui.value.copy(error = e.message ?: "Could not finish. Please try again."); false } |
| + finally { ui.value = ui.value.copy(busy = false, stage = null) } |
| + } |
| + } |
| + private fun refresh() { |
| + val current = client ?: return |
| + val view = JSONObject(current.refresh()) |
| + ui.value = ui.value.copy(identity = JSONObject(current.identityJSON()), threads = view.getJSONArray("threads").objects(), requests = view.getJSONArray("requests").objects()) |
| + } |
| + private fun activate(identity: String, claim: String? = null) { |
| + Ocho.validateIdentity(identity) |
| + val candidate = Ocho.newClient(identity) |
| + if (claim != null) candidate.setup(claim) |
| + val view = JSONObject(candidate.refresh()) |
| + secure.write("owner", identity); client = candidate |
| + ui.value = ui.value.copy(identity = JSONObject(identity), threads = view.getJSONArray("threads").objects(), requests = view.getJSONArray("requests").objects()) |
| + } |
| + suspend fun create(name: String) = run { |
| + require(name.trim().isNotEmpty() && name.length <= 80) { "Enter your name." } |
| + val pending = secure.read("pending") ?: kotlin.run { |
| + val nonce = secure.read("signup") ?: Ocho.random().also { secure.write("signup", it) } |
| + Ocho.register(HOME, name.trim(), nonce, Progress { message -> ui.value = ui.value.copy(stage = message) }).also { secure.write("pending", it) } |
| + } |
| + val value = JSONObject(pending) |
| + ui.value = ui.value.copy(stage = "Opening your private accountβ¦") |
| + activate(value.getJSONObject("identity").toString(), value.getString("claim")); secure.remove("pending"); secure.remove("signup") |
| + } |
| + suspend fun restore(file: String, password: String) = run { activate(Ocho.importRecovery(file, password)) } |
| + suspend fun signIn(identity: String) = run { activate(identity) } |
| + suspend fun invite(onLink: (String) -> Unit) = run { val link = client!!.invite(); withContext(Dispatchers.Main) { onLink(link) } } |
| + suspend fun connect(link: String) = run { client!!.connect(link); refresh() } |
| + suspend fun accept(request: JSONObject) = run { client!!.accept(request.getString("thread")); refresh() } |
| + suspend fun reject(request: JSONObject) = run { client!!.reject(request.getString("thread")); refresh() } |
| + suspend fun send(thread: String, text: String) = run { client!!.message(thread, text); refresh() } |
| + suspend fun group(name: String, contacts: Set<String>) = run { client!!.group(name, JSONArray(contacts.toList()).toString()); refresh() } |
| + suspend fun remove(thread: String, person: String) = run { client!!.removeMember(thread, person); refresh() } |
| + suspend fun recovery(password: String, ready: (String) -> Unit) = run { val file = Ocho.exportRecovery(client!!.identityJSON(), password); withContext(Dispatchers.Main) { ready(file) } } |
| + fun dismissError() { ui.value = ui.value.copy(error = null) } |
| + fun error(message: String) { ui.value = ui.value.copy(error = message) } |
| +} |
diff --git a/android/app/src/main/java/chat/ocho/app/MainActivity.kt b/android/app/src/main/java/chat/ocho/app/MainActivity.kt| new file mode 100644 |
| index 0000000..59b8308 |
| --- /dev/null |
| +++ b/android/app/src/main/java/chat/ocho/app/MainActivity.kt |
| @@ -0,0 +1,51 @@ |
| +// π€ Native Material/Compose Ocho; never instantiates a WebView. |
| +package chat.ocho.app |
| +import android.content.Intent |
| +import android.os.Bundle |
| +import androidx.activity.ComponentActivity |
| +import androidx.activity.compose.setContent |
| +import androidx.activity.result.contract.ActivityResultContracts |
| +import androidx.compose.foundation.isSystemInDarkTheme |
| +import androidx.compose.material3.* |
| +import androidx.compose.runtime.* |
| +import androidx.compose.ui.graphics.Color |
| +import androidx.lifecycle.viewmodel.compose.viewModel |
| +import kotlinx.coroutines.launch |
| + |
| +class MainActivity: ComponentActivity() { |
| + private var incoming by mutableStateOf("") |
| + override fun onCreate(savedInstanceState: Bundle?) { |
| + super.onCreate(savedInstanceState); go.Seq.setContext(applicationContext); incoming = invite(intent) |
| + setContent { |
| + val colors = if (isSystemInDarkTheme()) darkColorScheme(primary = Color(0xFFBCA7FF)) else lightColorScheme(primary = Color(0xFF684BCC)) |
| + MaterialTheme(colorScheme = colors) { |
| + val model: AccountModel = viewModel() |
| + val ui by model.ui.collectAsState() |
| + val scope = rememberCoroutineScope() |
| + var recovery by remember { mutableStateOf<String?>(null) } |
| + var exported by remember { mutableStateOf("") } |
| + var passkeyBusy by remember { mutableStateOf(false) } |
| + val open = androidx.activity.compose.rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> |
| + if (uri != null) try { contentResolver.openInputStream(uri)?.use { stream -> val output = java.io.ByteArrayOutputStream(); val buffer = ByteArray(4096); var count = stream.read(buffer); while (count > 0) { output.write(buffer, 0, count); require(output.size() <= 100000) { "Recovery file is too large." }; count = stream.read(buffer) }; val bytes = output.toByteArray(); require(bytes.size <= 100000) { "Recovery file is too large." }; recovery = bytes.toString(Charsets.UTF_8) } } catch (e: Exception) { model.error(e.message ?: "Could not read the recovery file.") } |
| + } |
| + val save = androidx.activity.compose.rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> |
| + if (uri != null) try { contentResolver.openOutputStream(uri)?.use { it.write(exported.toByteArray(Charsets.UTF_8)) } } catch (e: Exception) { model.error(e.message ?: "Could not save your recovery file.") } |
| + exported = "" |
| + } |
| + OchoScreen(model, ui, incoming, recovery, passkeyBusy, |
| + onConsumedInvite = { incoming = "" }, |
| + onRecovery = { open.launch(arrayOf("application/json")) }, |
| + onSave = { file -> exported = file; save.launch("Ocho recovery.json") }, |
| + onShare = { link -> startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, link), "Invite someone")) }, |
| + onPasskey = { enrolling -> scope.launch { |
| + passkeyBusy = true |
| + try { val passkeys = Passkeys(this@MainActivity); if (enrolling) { passkeys.add(ui.identity!!); model.error("Passkey saved. Use it on your other devices.") } else { model.signIn(passkeys.signIn()) } } |
| + catch (e: Exception) { model.error(e.message ?: "Passkey sign-in was cancelled.") } |
| + finally { passkeyBusy = false } |
| + } }) |
| + } |
| + } |
| + } |
| + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent); incoming = invite(intent) } |
| + private fun invite(intent: Intent): String = intent.data?.let { if (it.scheme == "ocho") it.getQueryParameter("url") ?: "" else it.toString() } ?: "" |
| +} |
diff --git a/android/app/src/main/java/chat/ocho/app/OchoScreen.kt b/android/app/src/main/java/chat/ocho/app/OchoScreen.kt| new file mode 100644 |
| index 0000000..4adeb05 |
| --- /dev/null |
| +++ b/android/app/src/main/java/chat/ocho/app/OchoScreen.kt |
| @@ -0,0 +1,88 @@ |
| +// π€ Flat native chats, consent requests and a platform-native composer. |
| +package chat.ocho.app |
| +import androidx.activity.compose.BackHandler |
| +import androidx.compose.foundation.background |
| +import androidx.compose.foundation.clickable |
| +import androidx.compose.foundation.layout.* |
| +import androidx.compose.foundation.lazy.LazyColumn |
| +import androidx.compose.foundation.lazy.items |
| +import androidx.compose.foundation.shape.RoundedCornerShape |
| +import androidx.compose.foundation.text.selection.SelectionContainer |
| +import androidx.compose.material3.* |
| +import androidx.compose.runtime.* |
| +import androidx.compose.ui.Alignment |
| +import androidx.compose.ui.Modifier |
| +import androidx.compose.ui.unit.dp |
| +import androidx.compose.ui.unit.sp |
| +import kotlinx.coroutines.launch |
| +import org.json.JSONObject |
| +import java.text.DateFormat |
| +import java.util.Date |
| + |
| +@OptIn(ExperimentalMaterial3Api::class) |
| +@Composable fun OchoScreen(model: AccountModel, ui: AccountUI, incoming: String, recovery: String?, passkeyBusy: Boolean, onConsumedInvite: () -> Unit, onRecovery: () -> Unit, onSave: (String) -> Unit, onShare: (String) -> Unit, onPasskey: (Boolean) -> Unit) { |
| + val scope = rememberCoroutineScope() |
| + var selected by remember { mutableStateOf<String?>(null) } |
| + var sheet by remember { mutableStateOf<String?>(null) } |
| + var menu by remember { mutableStateOf(false) } |
| + var query by remember { mutableStateOf("") } |
| + var draft by remember(selected) { mutableStateOf("") } |
| + val busy = ui.busy || passkeyBusy |
| + val thread = ui.threads.firstOrNull { it.getString("id") == selected } |
| + BackHandler(selected != null) { selected = null } |
| + LaunchedEffect(incoming, ui.identity != null) { if (incoming.isNotEmpty() && ui.identity != null) sheet = "connect" } |
| + Scaffold(topBar = { |
| + if (ui.identity != null) TopAppBar(title = { Text(thread?.getString("name") ?: "Ocho") }, navigationIcon = { if (selected != null) TextButton(onClick = { selected = null }) { Text("Back") } }, actions = { |
| + if (thread != null) TextButton(onClick = { sheet = "details" }) { Text("Details") } |
| + TextButton(onClick = { menu = true }) { Text("More") } |
| + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { |
| + DropdownMenuItem(text = { Text("Invite someone") }, enabled = !busy, onClick = { menu = false; scope.launch { model.invite(onShare) } }) |
| + listOf("connect" to "Connect with a link", "group" to "New group", "recovery" to "Recovery & another device").forEach { (key, label) -> DropdownMenuItem(text = { Text(label) }, onClick = { menu = false; sheet = key }) } |
| + } |
| + }) |
| + }) { padding -> |
| + Column(Modifier.fillMaxSize().padding(padding).imePadding()) { |
| + if (ui.error != null) Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.secondaryContainer).padding(12.dp), verticalAlignment = Alignment.CenterVertically) { Text(ui.error, Modifier.weight(1f)); TextButton(onClick = model::dismissError) { Text("Dismiss") } } |
| + when { |
| + ui.identity == null -> WelcomeScreen(ui, busy, recovery, onRecovery, { onPasskey(false) }, { name -> scope.launch { model.create(name) } }, { file, password -> scope.launch { model.restore(file, password) } }) |
| + thread == null -> { |
| + OutlinedTextField(value = query, onValueChange = { query = it }, label = { Text("Search chats") }, modifier = Modifier.fillMaxWidth().padding(16.dp), singleLine = true) |
| + LazyColumn(Modifier.weight(1f)) { |
| + items(ui.requests, key = { "request:" + it.getString("thread") }) { request -> |
| + Column(Modifier.fillMaxWidth().padding(16.dp)) { |
| + Text(request.optString("name").ifEmpty { request.optJSONObject("person")?.optString("name") ?: "New conversation" }, style = MaterialTheme.typography.titleMedium) |
| + Row { TextButton(enabled = !busy, onClick = { scope.launch { model.accept(request) } }) { Text("Accept") }; TextButton(enabled = !busy, onClick = { scope.launch { model.reject(request) } }) { Text("Decline") } } |
| + } |
| + } |
| + items(ui.threads.filter { it.getString("name").contains(query, true) }, key = { it.getString("id") }) { item -> |
| + Column(Modifier.fillMaxWidth().clickable { selected = item.getString("id") }.padding(16.dp)) { |
| + Text(item.getString("name"), style = MaterialTheme.typography.titleMedium) |
| + val messages = item.getJSONArray("messages") |
| + Text(if (item.optBoolean("pending")) "Waiting for them to acceptβ¦" else if (messages.length() > 0) messages.getJSONObject(messages.length() - 1).getString("text") else "Say hello", maxLines = 2, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| + }; HorizontalDivider() |
| + } |
| + if (ui.threads.isEmpty() && ui.requests.isEmpty()) item { Column(Modifier.padding(32.dp)) { Text("Your people, right here", style = MaterialTheme.typography.headlineSmall); Text("Share an invitation to start a conversation."); Button(enabled = !busy, onClick = { scope.launch { model.invite(onShare) } }) { Text("Invite someone") } } } |
| + } |
| + } |
| + else -> { |
| + val messages = thread.getJSONArray("messages").objects() |
| + LazyColumn(Modifier.weight(1f).fillMaxWidth(), reverseLayout = true, contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { |
| + items(messages.reversed(), key = { it.getString("id") }) { message -> |
| + val mine = message.getBoolean("mine") |
| + Column(Modifier.fillMaxWidth(), horizontalAlignment = if (mine) Alignment.End else Alignment.Start) { |
| + if (thread.getBoolean("group") && !mine) Text(message.getString("name"), style = MaterialTheme.typography.labelSmall) |
| + SelectionContainer { Text(message.getString("text"), Modifier.widthIn(max = 320.dp).background(if (mine) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(18.dp)).padding(12.dp)) } |
| + Text(DateFormat.getTimeInstance(DateFormat.SHORT).format(Date(message.getLong("at"))) + if (mine) " Β· " + message.optString("delivery") else "", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) |
| + } |
| + } |
| + } |
| + if (thread.getBoolean("left")) Text("Youβre no longer in this group.", Modifier.padding(16.dp)) else Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.Bottom) { |
| + OutlinedTextField(value = draft, onValueChange = { if (it.length <= 8000) draft = it }, label = { Text("Message") }, modifier = Modifier.weight(1f), maxLines = 6) |
| + TextButton(enabled = !busy && !thread.getBoolean("pending") && draft.isNotBlank(), onClick = { val text = draft; scope.launch { if (model.send(thread.getString("id"), text) && draft == text) draft = "" } }) { Text("Send") } |
| + } |
| + } |
| + } |
| + } |
| + } |
| + if (sheet != null) AccountDialogs(sheet!!, model, ui, thread, incoming, onPasskey, onSave) { if (sheet == "connect") onConsumedInvite(); sheet = null } |
| +} |
diff --git a/android/app/src/main/java/chat/ocho/app/Passkeys.kt b/android/app/src/main/java/chat/ocho/app/Passkeys.kt| new file mode 100644 |
| index 0000000..6e00dfb |
| --- /dev/null |
| +++ b/android/app/src/main/java/chat/ocho/app/Passkeys.kt |
| @@ -0,0 +1,55 @@ |
| +// π€ Credential Manager presents system passkeys. PRF material stays client-side. |
| +package chat.ocho.app |
| +import android.app.Activity |
| +import android.util.Base64 |
| +import androidx.credentials.* |
| +import chat.ocho.engine.ocho.Ocho |
| +import kotlinx.coroutines.Dispatchers |
| +import kotlinx.coroutines.withContext |
| +import org.json.JSONArray |
| +import org.json.JSONObject |
| + |
| +class Passkeys(private val activity: Activity) { |
| + private val manager = CredentialManager.create(activity) |
| + private fun decode(value: String): ByteArray = Base64.decode(value, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) |
| + private suspend fun post(path: String, body: JSONObject, auth: String = ""): JSONObject = withContext(Dispatchers.IO) { |
| + JSONObject(Ocho.requestJSON(AccountModel.HOME, "/api/passkeys/$path", auth, "POST", body.toString())) |
| + } |
| + private fun extension(options: JSONObject) = JSONObject().put("prf", JSONObject().put("eval", JSONObject().put("first", options.getString("salt")))) |
| + private suspend fun assertion(options: JSONObject, id: String? = null): JSONObject { |
| + val request = JSONObject().put("challenge", options.getString("challenge")).put("rpId", options.getString("rpId")).put("userVerification", "required").put("timeout", 120000).put("extensions", extension(options)) |
| + if (id != null) request.put("allowCredentials", JSONArray().put(JSONObject().put("type", "public-key").put("id", id))) |
| + val response = withContext(Dispatchers.Main) { manager.getCredential(activity, GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request.toString())))) } |
| + val credential = response.credential as? PublicKeyCredential ?: error("Choose an Ocho passkey.") |
| + return JSONObject(credential.authenticationResponseJson) |
| + } |
| + private fun output(credential: JSONObject): String? = credential.optJSONObject("clientExtensionResults")?.optJSONObject("prf")?.optJSONObject("results")?.optString("first")?.takeIf { it.isNotEmpty() } |
| + private fun wrappingKey(prf: String?, credential: JSONObject): String { |
| + require(prf != null) { "This passkey provider cannot unlock encrypted messages. Use a recovery file or another provider." } |
| + return Ocho.passkeyWrappingKey(decode(prf), decode(credential.getString("id"))) |
| + } |
| + suspend fun add(identity: JSONObject) { |
| + val options = post("register/begin", JSONObject().put("origin", identity.getString("origin")), identity.getString("auth")) |
| + require(options.getString("rpId") == "ocho.chat") |
| + val request = JSONObject().put("challenge", options.getString("challenge")) |
| + .put("rp", JSONObject().put("id", "ocho.chat").put("name", "Ocho")) |
| + .put("user", JSONObject().put("id", options.getString("userHandle")).put("name", identity.getString("name")).put("displayName", identity.getString("name"))) |
| + .put("pubKeyCredParams", JSONArray().put(JSONObject().put("type", "public-key").put("alg", -7))) |
| + .put("authenticatorSelection", JSONObject().put("residentKey", "required").put("userVerification", "required")) |
| + .put("attestation", "none").put("timeout", 120000).put("extensions", extension(options)) |
| + val result = withContext(Dispatchers.Main) { manager.createCredential(activity, CreatePublicKeyCredentialRequest(request.toString())) } as? CreatePublicKeyCredentialResponse ?: error("Could not create a passkey.") |
| + val credential = JSONObject(result.registrationResponseJson) |
| + var prf = output(credential) |
| + if (prf == null) { val existing = assertion(options, credential.getString("id")); if (existing.getString("id") == credential.getString("id")) prf = output(existing) } |
| + val key = wrappingKey(prf, credential) |
| + val box = withContext(Dispatchers.IO) { Ocho.lock(key, identity.toString()) } |
| + val response = credential.getJSONObject("response") |
| + post("register/finish", JSONObject().put("challenge", options.getString("challenge")).put("clientDataJSON", response.getString("clientDataJSON")).put("attestationObject", response.getString("attestationObject")).put("box", box), identity.getString("auth")) |
| + } |
| + suspend fun signIn(): String { |
| + val options = post("login/begin", JSONObject()); require(options.getString("rpId") == "ocho.chat") |
| + val credential = assertion(options); val key = wrappingKey(output(credential), credential); val response = credential.getJSONObject("response") |
| + val result = post("login/finish", JSONObject().put("challenge", options.getString("challenge")).put("credentialId", credential.getString("id")).put("userHandle", response.getString("userHandle")).put("clientDataJSON", response.getString("clientDataJSON")).put("authenticatorData", response.getString("authenticatorData")).put("signature", response.getString("signature"))) |
| + return withContext(Dispatchers.IO) { Ocho.unlock(key, result.getString("box")).also { Ocho.validateIdentity(it); require(JSONObject(it).getString("origin") == result.getString("origin")) } } |
| + } |
| +} |
diff --git a/android/app/src/main/java/chat/ocho/app/SecureStore.kt b/android/app/src/main/java/chat/ocho/app/SecureStore.kt| new file mode 100644 |
| index 0000000..2da0cec |
| --- /dev/null |
| +++ b/android/app/src/main/java/chat/ocho/app/SecureStore.kt |
| @@ -0,0 +1,38 @@ |
| +// π€ Android Keystore protects the shared engine's identity locally; no cloud backup. |
| +package chat.ocho.app |
| +import android.content.Context |
| +import android.security.keystore.KeyGenParameterSpec |
| +import android.security.keystore.KeyProperties |
| +import android.util.Base64 |
| +import java.security.KeyStore |
| +import javax.crypto.Cipher |
| +import javax.crypto.KeyGenerator |
| +import javax.crypto.SecretKey |
| +import javax.crypto.spec.GCMParameterSpec |
| + |
| +class SecureStore(context: Context) { |
| + private val preferences = context.getSharedPreferences("ocho.private", Context.MODE_PRIVATE) |
| + private val alias = "chat.ocho.identity" |
| + private fun key(): SecretKey { |
| + val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } |
| + (store.getKey(alias, null) as? SecretKey)?.let { return it } |
| + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply { |
| + init(KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT).setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).setKeySize(256).build()) |
| + }.generateKey() |
| + } |
| + fun read(name: String): String? { |
| + val saved = preferences.getString(name, null) ?: return null |
| + val bytes = Base64.decode(saved, Base64.NO_WRAP) |
| + require(bytes.size >= 28) { "Saved account is incomplete. Use your passkey or recovery file." } |
| + val cipher = Cipher.getInstance("AES/GCM/NoPadding") |
| + cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12))) |
| + cipher.updateAAD(name.toByteArray(Charsets.UTF_8)) |
| + return cipher.doFinal(bytes.copyOfRange(12, bytes.size)).toString(Charsets.UTF_8) |
| + } |
| + fun write(name: String, value: String) { |
| + val cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init(Cipher.ENCRYPT_MODE, key()) |
| + cipher.updateAAD(name.toByteArray(Charsets.UTF_8)) |
| + check(preferences.edit().putString(name, Base64.encodeToString(cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)).commit()) { "Could not securely save your account." } |
| + } |
| + fun remove(name: String) { check(preferences.edit().remove(name).commit()) } |
| +} |
diff --git a/android/app/src/main/res/drawable/ocho_icon.png b/android/app/src/main/res/drawable/ocho_icon.png| new file mode 100644 |
| index 0000000..8bab6bb |
| Binary files /dev/null and b/android/app/src/main/res/drawable/ocho_icon.png differ |
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml| new file mode 100644 |
| index 0000000..0770eb6 |
| --- /dev/null |
| +++ b/android/app/src/main/res/values/styles.xml |
| @@ -0,0 +1 @@ |
| +<resources><style name="OchoTheme" parent="android:style/Theme.Material.Light.NoActionBar"><item name="android:fontFamily">sans</item><item name="android:colorAccent">#684BCC</item><item name="android:windowLightStatusBar">true</item></style></resources> |
diff --git a/android/build.gradle.kts b/android/build.gradle.kts| new file mode 100644 |
| index 0000000..db0f43e |
| --- /dev/null |
| +++ b/android/build.gradle.kts |
| @@ -0,0 +1,5 @@ |
| +plugins { |
| + id("com.android.application") version "8.9.2" apply false |
| + id("org.jetbrains.kotlin.android") version "2.1.20" apply false |
| + id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false |
| +} |
diff --git a/android/gradle.properties b/android/gradle.properties| new file mode 100644 |
| index 0000000..e696167 |
| --- /dev/null |
| +++ b/android/gradle.properties |
| @@ -0,0 +1,3 @@ |
| +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 |
| +android.useAndroidX=true |
| +kotlin.code.style=official |
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar| new file mode 100644 |
| index 0000000..a4b76b9 |
| 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 0000000..9751024 |
| --- /dev/null |
| +++ b/android/gradle/wrapper/gradle-wrapper.properties |
| @@ -0,0 +1,8 @@ |
| +distributionBase=GRADLE_USER_HOME |
| +distributionPath=wrapper/dists |
| +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip |
| +networkTimeout=10000 |
| +validateDistributionUrl=true |
| +zipStoreBase=GRADLE_USER_HOME |
| +zipStorePath=wrapper/dists |
| +distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6 |
diff --git a/android/gradlew b/android/gradlew| new file mode 100755 |
| index 0000000..f5feea6 |
| --- /dev/null |
| +++ b/android/gradlew |
| @@ -0,0 +1,252 @@ |
| +#!/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. |
| +# |
| +# SPDX-License-Identifier: Apache-2.0 |
| +# |
| + |
| +############################################################################## |
| +# |
| +# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s |
| +' "$PWD" ) || 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/settings.gradle.kts b/android/settings.gradle.kts| new file mode 100644 |
| index 0000000..a7ef00c |
| --- /dev/null |
| +++ b/android/settings.gradle.kts |
| @@ -0,0 +1,5 @@ |
| +// π€ Native Android UI over the shared Go engine. |
| +pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } |
| +dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS); repositories { google(); mavenCentral() } } |
| +rootProject.name = "Ocho" |
| +include(":app") |
diff --git a/apple/Infinity/WelcomeView.swift b/apple/Infinity/WelcomeView.swift| index 08ba692..525f052 100644 |
| --- a/apple/Infinity/WelcomeView.swift |
| +++ b/apple/Infinity/WelcomeView.swift |
| @@ -1,10 +1,15 @@ |
| // π€ Native onboarding and file recovery. No embedded browser. |
| import SwiftUI |
| import UniformTypeIdentifiers |
| +import OchoCore |
| +#if os(macOS) |
| +import Darwin |
| +#endif |
| struct WelcomeView: View { |
| @EnvironmentObject var account: AccountStore |
| @State private var name = "" |
| @State private var filePicker = false |
| + @State private var legacyPicker = false |
| @State private var recoveryData: Data? |
| @State private var password = "" |
| var body: some View { |
| @@ -19,6 +24,9 @@ struct WelcomeView: View { |
| Button("Sign in with a passkey") { Task { await account.signInPasskey() } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy) |
| TextField("Your name", text: $name).textFieldStyle(.roundedBorder).accessibilityIdentifier("signup-name") |
| Button("Create your account") { Task { await account.create(name: name) } }.buttonStyle(.borderedProminent).controlSize(.large).disabled(account.busy || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) |
| + #if os(macOS) |
| + Button("Import from the earlier Mac app") { legacyPicker = true }.disabled(account.busy) |
| + #endif |
| Button("I have a recovery file") { filePicker = true }.disabled(account.busy) |
| if recoveryData != nil { |
| SecureField("Recovery password", text: $password).textFieldStyle(.roundedBorder) |
| @@ -28,6 +36,19 @@ struct WelcomeView: View { |
| } |
| }.frame(maxWidth: 520, alignment: .leading).padding(32).frame(maxWidth: .infinity) |
| } |
| + #if os(macOS) |
| + .fileDialogDefaultDirectory(URL(fileURLWithPath: String(cString: getpwuid(getuid()).pointee.pw_dir)).appendingPathComponent("Library/WebKit/chat.ocho.infinity.mac/WebsiteData")) |
| + #endif |
| + .fileImporter(isPresented: $legacyPicker, allowedContentTypes: [.folder]) { result in |
| + do { |
| + let url = try result.get(); let access = url.startAccessingSecurityScopedResource() |
| + Task { await account.perform { |
| + defer { if access { url.stopAccessingSecurityScopedResource() } } |
| + guard let id = try LegacyIdentity.discover(in: [url], preferredOrigin: UserDefaults.standard.string(forKey: "infinity.account-origin")) else { throw OchoError.invalid("No saved Ocho account was found in that folder.") } |
| + try await account.activate(id) |
| + } } |
| + } catch { account.error = error.localizedDescription } |
| + } |
| .fileImporter(isPresented: $filePicker, allowedContentTypes: [.json]) { result in |
| do { let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } }; recoveryData = try Data(contentsOf: url) } |
| catch { account.error = error.localizedDescription } |
diff --git a/apple/README.md b/apple/README.md| index ca11a87..4c72938 100644 |
| --- a/apple/README.md |
| +++ b/apple/README.md |
| @@ -1,16 +1,26 @@ |
| -# π€ β for iPhone, iPad and Mac |
| +# π€ Ocho for iPhone, iPad and Mac |
| -The initial Apple apps use the same WebKit chat client as the web version, in a SwiftUI shell. Encryption runs in WebKit; origin-isolated storage belongs to these apps, with no Eleven shared keychain group or account container. |
| +Fully native SwiftUI clients. No WebViews or JavaScript runtime. The shared Go engine owns cryptography, networking, registration and chat state; `OchoCore` contains thin Swift adapters and value types. Keychain stores the private identity and AuthenticationServices presents passkeys for `ocho.chat`. |
| -Build from the repository root: |
| +## Build and verify |
| +Requires Xcode, xcodegen and the Go toolchains pinned in the root and `mobile/go.mod`. |
| + |
| +```sh |
| +tools/infinity/build-mobile apple |
| +xcodegen generate --spec apple/project.yml |
| +tools/infinity/check-native |
| +tools/infinity/check-native-ui |
| ``` |
| -tools/infinity/check mac |
| -tools/infinity/check ios |
| -``` |
| -Requires Xcode and xcodegen. Both targets have been compiled without signing against the installed Mac and iOS Simulator SDKs. A device/App Store build needs a signing team and provisioning profiles for `chat.ocho.infinity` and `chat.ocho.infinity.mac`. |
| +The last command uses two disposable local personal processes and a dedicated iPhone simulator (override `OCHO_SIMULATOR_ID`). It verifies native receipt/composition/send, peer decryption and zero WebViews. The package tests cover Web Crypto interoperability, group integrity, restored history and read-only legacy migration. |
| + |
| +Build the generated `Infinity` or `InfinityMac` Xcode scheme. Device signing needs a team/profile for `chat.ocho.infinity` or `chat.ocho.infinity.mac`, with `webcredentials:ocho.chat` and the matching server association. Both apps display **Ocho**. Physical passkey ceremonies need user presence and a PRF-capable provider; unsupported providers retain explicit recovery-file fallback. |
| + |
| +## Existing accounts |
| + |
| +The native app reads the earlier wrapper's local identity directly from SQLite, validates it, and saves it in Keychain. It never loads WebKit or deletes the old files. On Mac the earlier unsigned app stored data outside the new sandbox, so use **Import from the earlier Mac app** and approve the preselected WebsiteData folder in the system picker. Ambiguous identities require explicit passkey/recovery selection. |
| -The release home is `https://ocho.chat`. For a Debug build, `INFINITY_HOME` may point to the hosted review origin. A recovery export opens the system save/share UI; restoring uses the file picker in the shared client. `ocho://open?url=<encoded HTTPS URL>` opens a link. Universal links and native background notifications are not yet configured; these are explicit release follow-ups, not simulated features. |
| +Passkeys and recovery files unlock the same account/history on web and native. `ocho://open?url=<encoded invitation>` opens native invitations. Universal links, native background notifications and store distribution remain follow-ups. |
| -The logo comes from the Myriad Pro infinity glyph, outlined once by `tools/infinity/make-mark.swift` and rendered at required sizes by `tools/infinity/render-icons.mjs`. The font file is not distributed. |
| +The icon uses the outlined Myriad Pro infinity glyph. No font file is distributed. |
diff --git a/apple/project.yml b/apple/project.yml| index 115c04c..ded8bb0 100644 |
| --- a/apple/project.yml |
| +++ b/apple/project.yml |
| @@ -7,8 +7,8 @@ options: |
| settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| - MARKETING_VERSION: '0.1.0' |
| - CURRENT_PROJECT_VERSION: '2' |
| + MARKETING_VERSION: '0.2.0' |
| + CURRENT_PROJECT_VERSION: '3' |
| GENERATE_INFOPLIST_FILE: YES |
| ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon |
| CODE_SIGN_STYLE: Automatic |
diff --git a/cmd/infinity-home/main.go b/cmd/infinity-home/main.go| index 91d5d66..4c4eb09 100644 |
| --- a/cmd/infinity-home/main.go |
| +++ b/cmd/infinity-home/main.go |
| @@ -30,7 +30,7 @@ func run() error { |
| } |
| defer d.Close() |
| key, _ := base64.RawURLEncoding.DecodeString(os.Getenv("INFINITY_CLAIM_KEY")) |
| - h, e := infinityhome.New(d, infinityhome.Config{Origin: os.Getenv("INFINITY_ORIGIN"), Console: "https://console.carloku.com", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: os.Getenv("INFINITY_CHANNEL"), Token: os.Getenv("INFINITY_PROVISION_TOKEN"), ClaimKey: key}) |
| + h, e := infinityhome.New(d, infinityhome.Config{AndroidCertSHA256: os.Getenv("INFINITY_ANDROID_CERT_SHA256"), Origin: os.Getenv("INFINITY_ORIGIN"), Console: "https://console.carloku.com", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Channel: os.Getenv("INFINITY_CHANNEL"), Token: os.Getenv("INFINITY_PROVISION_TOKEN"), ClaimKey: key}) |
| if e != nil { |
| return e |
| } |
diff --git a/docs/infinity/android-handoff.md b/docs/infinity/android-handoff.md| index 127f698..668332c 100644 |
| --- a/docs/infinity/android-handoff.md |
| +++ b/docs/infinity/android-handoff.md |
| @@ -2,7 +2,7 @@ |
| Paul requested Android and a shared Go/gomobile engine while the fully native Apple rewrite was underway. This supersedes independent Swift protocol implementations as the shipping architecture. SwiftUI stays native; Android should use native Kotlin/Compose. No WebViews or JavaScript runtimes in native apps. |
| -Working checkout: `/Users/paulcampbell/amadan.net/ocho/ocho/.worktrees/infinity`, branch `infinity-v1`, Amadan `eleven/ocho`. Do not edit the original Eleven checkout or merge Ocho main. Current prototype Apple sources are under `apple/`; the shared engine is being introduced under `mobile/`. The Swift/Web Crypto compatibility and native integration tests are reusable acceptance evidence for the Go replacement, not a reason to keep two protocol implementations. |
| +Working checkout: `/Users/paulcampbell/amadan.net/ocho/ocho/.worktrees/infinity`, branch `infinity-v1`, Amadan `eleven/ocho`. Do not edit the original Eleven checkout or merge Ocho main. Current prototype Apple sources are under `apple/`; the shared engine lives under `mobile/`. The Swift/Web Crypto compatibility and native integration tests are reusable acceptance evidence for the Go replacement, not a reason to keep two protocol implementations. |
| ## Binding boundary |
| @@ -12,7 +12,7 @@ Ocho v1 wire format remains unchanged: P-256 ECDH, HKDF-SHA256 (`infinity-messag |
| ## Passkeys |
| -RP ID `ocho.chat`; discoverable credentials with required user verification. Native Android uses Credential Manager and Digital Asset Links for the exact Android application ID/signing certificate. No certificate fingerprints or Android association have been invented yet. Register/login endpoints are `/api/passkeys/{register,login}/{begin,finish}` at the front door. PRF input bytes are UTF-8 `ocho-passkey-identity-v1`. Derive wrapping key via HKDF-SHA256, salt UTF-8 `ocho-passkey-wrap-v1`, info raw credential ID, 32 bytes; wrap identity using the existing vault box format. The PRF output stays entirely client-side. Unsupported PRF requires explicit recovery-file fallback, never server-readable key escrow. |
| +RP ID `ocho.chat`; discoverable credentials with required user verification. Native Android uses Credential Manager and Digital Asset Links for the exact Android application ID/signing certificate. The development package is `chat.ocho.app`; its actual APK signing certificate SHA-256 is `a721f3333aea99b0b4346941d7b2f82844492b8417bb2b5499f02e013c3dc36d`. Association configuration must match the installed certificate, and change for store distribution. Register/login endpoints are `/api/passkeys/{register,login}/{begin,finish}` at the front door. PRF input bytes are UTF-8 `ocho-passkey-identity-v1`. Derive wrapping key via HKDF-SHA256, salt UTF-8 `ocho-passkey-wrap-v1`, info raw credential ID, 32 bytes; wrap identity using the existing vault box format. The PRF output stays entirely client-side. Unsupported PRF requires explicit recovery-file fallback, never server-readable key escrow. |
| ## Machine tooling inventory |
| @@ -23,3 +23,9 @@ Cross-session messaging discovery currently fails because its local MCP endpoint |
| ## Updated machine observation |
| The Eleven Android session is `01a0788c-520f-76c2-9edf-4a3d2ee19721`. Its active installer is populating `~/Library/Android/sdk` with command-line tools 19.0, platform-tools, platforms;android-35, build-tools;35.0.0, ndk;28.2.13676358 and an API35 arm64 emulator. JDK path: `/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home`. Ocho will reuse these paths and will not run a second installer. Cross-session message delivery has not succeeded yet. |
| + |
| +## Build results and isolation |
| + |
| +Ocho successfully built the XCFramework, Android AAR and native Compose debug APK using the already-complete SDK at `~/github.com/kasstraining/kass-native/mobile/toolchain/sdk` and the JDK above. No duplicate installer was started. `~/Library/Android/sdk` is also being populated by the Eleven session; use that shared installation once complete. `ANDROID_HOME` stays an explicit build input, not a hardcoded personal path in project scripts. |
| + |
| +Ocho has its own `OchoNativeReview` AVD on **emulator-5562**. Eleven's **emulator-5554** was left untouched. See `tools/infinity/check-android` for the real native UI test. Cross-session delivery attempts failed because the local MCP endpoint is unavailable; these notes have not been delivered through that tool. |
diff --git a/docs/infinity/native-passkeys.md b/docs/infinity/native-passkeys.md| index a49df29..7ae963d 100644 |
| --- a/docs/infinity/native-passkeys.md |
| +++ b/docs/infinity/native-passkeys.md |
| @@ -4,7 +4,7 @@ Paul requires fully native iOS/macOS apps, no WebViews anywhere, passkeys as an |
| ## Native client |
| -Use Eleven's native chat as a reference for navigation, a flat conversation list, native message feed, composer, group sheets and sharing. Do not import its command-host WebViews or its different wire protocol. Implement Ocho's current version-1 envelope and vault protocol in a shared Swift/CryptoKit package, with bidirectional Web Crypto compatibility fixtures. Use URLSession for networking, Keychain for saved identities, SwiftUI for all screens and AuthenticationServices for passkeys. Existing web events must remain readable. No account reset, destructive migration, or server-side decryption. |
| +Use Eleven's native chat as a reference for navigation, a flat conversation list, native message feed, composer, group sheets and sharing. Do not import its command-host WebViews or its different wire protocol. Ocho's version-1 envelope and vault protocol lives in one Go/gomobile engine under `mobile/`, with bidirectional Web Crypto compatibility fixtures. Go owns encryption, verified event projection, networking, registration and serialized account state. SwiftUI and Kotlin/Compose provide native screens; Keychain and Android Keystore protect identities; AuthenticationServices and Credential Manager provide system passkey ceremonies. Existing web events must remain readable. No account reset, destructive migration, or server-side decryption. |
| ## One web origin |
| @@ -12,11 +12,11 @@ Keep the client and its stored identity at ocho.chat. Tenant processes and datab |
| ## Passkeys |
| -Relying-party ID ocho.chat, shared by web and associated native applications. Use discoverable credentials and required user verification. WebAuthn challenges are short-lived, single-use and bound to their operation. Use the existing maintained WebAuthn/Rastrillo facilities for verification, not custom signature verification. A credential's PRF output derives a domain-separated key that encrypts the existing Ocho identity. The server stores the credential public metadata, tenant association and encrypted identity only; never PRF output or private keys. Validate ownership before enrolling a credential for an existing tenant. Signing in verifies the assertion, retrieves the sealed identity and decrypts on the client before loading the existing encrypted vault and events. A passkey without PRF support cannot silently become an unencrypted recovery mechanism. Keep recovery-file support as an explicit alternative. Native and browser PRF salt/derivation must be tested together; associated-domain signing is a real-device prerequisite. |
| +Relying-party ID ocho.chat, shared by web and associated native applications. Use discoverable credentials and required user verification. WebAuthn challenges are short-lived, single-use and bound to their operation. Use the existing maintained WebAuthn/Rastrillo facilities for verification, not custom signature verification. A credential's PRF output derives a domain-separated key that encrypts the existing Ocho identity. The server stores the credential public metadata, tenant association and encrypted identity only; never PRF output or private keys. Validate ownership before enrolling a credential for an existing tenant. Signing in verifies the assertion, retrieves the sealed identity and decrypts on the client before loading the existing encrypted vault and events. A passkey without PRF support cannot silently become an unencrypted recovery mechanism. Keep recovery-file support as an explicit alternative. Native and browser PRF salt/derivation must be tested together; associated-domain signing is a real-device prerequisite. Apple associations name the signed application identifiers; Android Digital Asset Links and assertion origins pin the exact SHA-256 app signing certificate configured through `INFINITY_ANDROID_CERT_SHA256`. The development association must be replaced with the distribution certificate before publishing a release. |
| ## Verification and rollout |
| -CryptoKit/Web Crypto interoperability and tamper rejection; projection parity including group membership epochs and forged events; relay cross-tenant and SSRF rejection; passkey replay/origin/ownership and encrypted-storage tests; browser signup/invitation/recovery/chat flows at a stable origin; native builds and simulator UI flows; physical-device passkey test when user presence is available. Deploy compatible backend/web changes before native installation. A one-time read-only SQLite migration reads only the old appβs identity item from its own local-storage directories, validates the key pairs and saves it in Keychain. It loads no WebKit runtime and leaves old files untouched. Multiple ambiguous saved identities require explicit recovery/passkey selection; never choose arbitrarily. No implementation merge to main without Paul's approval. |
| +Go/Web Crypto interoperability through both native bindings and tamper rejection; projection parity including group membership epochs and forged events; relay cross-tenant and SSRF rejection; passkey replay/origin/ownership and encrypted-storage tests; browser signup/invitation/recovery/chat flows at a stable origin; native builds and simulator UI flows; physical-device passkey test when user presence is available. Deploy compatible backend/web changes before native installation. A one-time read-only SQLite migration reads only the old appβs identity item from its own local-storage directories, validates the key pairs and saves it in Keychain. It loads no WebKit runtime and leaves old files untouched. Multiple ambiguous saved identities require explicit recovery/passkey selection; never choose arbitrarily. No implementation merge to main without Paul's approval. |
| ## Android steering β Paul, 2026-09-06 |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 5b277e8..7deb2a9 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -1,63 +1,38 @@ |
| -# π€ β status β 2026-09-06 |
| +# π€ Ocho status β 2026-09-06 |
| ## Try it |
| -https://ocho.chat is live with verified DNS and HTTPS. Public signup, signup through an invitation, consent and bidirectional encrypted messages passed a fresh WebKit drive after the platform fix. The two fictional accounts completed the full flow in 54 seconds; no browser errors occurred. |
| +https://ocho.chat is live. Signup, signed-in chat, passkey sign-in and recovery stay on that origin; each person still has a separate process and database. Existing tenant accounts can move through **Bring your existing account to Ocho**, a passkey, or an encrypted recovery file. |
| -## Implemented |
| +Native SwiftUI iPhone and Mac apps replace the WebKit wrappers. The signed iPhone build was installed on Paul's paired iPhone; first launch was blocked because the phone was locked. The Mac build is open. The earlier unsigned Mac app stored its identity outside the new sandbox: use **Import from the earlier Mac app** and approve its WebsiteData folder in the native picker. macOS denied an attempted direct migration into the protected container, so the app uses this explicit user-granted import. No old account data was deleted or reset. |
| -Carlos personal-instance runtime; separate registration binary with an app-scoped instance credential, public-key-verified setup tickets, sealed proof of work and a persisted daily account budget; signed, encrypted, consent-gated DMs; group creation/acceptance/removal; flat mobile/desktop chat; ciphertext history in every participant's instance; durable outbox, retries and delivery status; encrypted account-state compare-and-swap; password-encrypted recovery and another-device restore; Myriad Pro infinity outline; isolated iOS/macOS WebKit apps. |
| +Android now has a native Kotlin/Compose app and a buildable debug APK under `android/app/build/outputs/apk/debug/app-debug.apk`. It is tested on the dedicated OchoNativeReview emulator, not installed on a physical Android phone or published to a store. |
| -The browser keeps private keys. Invitation links survive front-door navigation, new-account setup, saved-account navigation and recovery. Requests abort after 30 seconds; signup has a two-minute deadline and distinguishes device checking, allocation and startup. Retry preserves the registration nonce and continues the same account. The broker persists confirmed allocation so readiness polls do not repeat provisioning. |
| +## Architecture |
| -## Validation executed |
| +All native clients share the Go/gomobile engine in `mobile/`: identity, envelope/vault/recovery/PRF cryptography, verified event projection, serialized state, account setup, networking, DMs, groups and membership. SwiftUI and Compose own presentation; Keychain/Keystore own local secure storage; AuthenticationServices/Credential Manager own passkey prompts. Native production sources contain no WebViews or JavaScript runtime. The icon is the infinity glyph; the application name is Ocho. |
| -All inherited Go and Node tests passed during implementation. New Go tests/vet, Node crypto/projection tests and three-instance Chromium/WebKit drives passed, including DMs, groups, member removal, reload history, idempotent setup, encrypted recovery, mobile layout, raw SQLite/WAL plaintext absence and browser-error checks. Framework registration-worker verification passed under strict CSP. |
| +The Carlos front door relays only enumerated APIs to known provisioned tenants. Owner authentication remains enforced by each personal process. Rastrillo verifies WebAuthn ceremonies with required user verification, short-lived single-use challenges, ownership checks and pinned origins. Servers store public credential metadata plus an identity encrypted by a passkey PRF-derived key; PRF outputs and private keys never reach them. Recovery files remain available when a provider cannot supply PRF output. |
| -The timeout change passed local Chromium/WebKit regressions for stalled requests, form recovery and nonce reuse, plus a deployed WebKit simulation on ocho.chat that submitted no registration. The latest public WebKit test creates its second account by following the first account's invitation, then verifies consent and encrypted messaging. Earlier tests also verified invitation navigation into an existing recipient account. |
| +## Verification |
| -Both Apple targets compiled with signing disabled after fixing account persistence on asynchronous setup/restore. They have not been distributed through TestFlight or a Mac installer. govulncheck found no reachable vulnerabilities with Go 1.25.13. |
| +- Seven native Go/Swift acceptance tests pass: bidirectional Web Crypto envelope/vault/recovery/PRF fixtures, tamper rejection, three real personal processes, DMs/groups/consent/removal/history and legacy SQLite migration. |
| +- Actual SwiftUI iPhone simulator test passes receipt, composer send, peer decryption and zero WebViews. |
| +- Actual Android Compose emulator test passes receipt, composer send, peer decryption, Keystore persistence and restored history after activity recreation; zero WebViews in the view hierarchy. Android debug build and lint pass. |
| +- Server tests/vet and the full Chromium/WebKit multi-instance chat suite pass, including raw database/WAL plaintext absence. Passkey tests reject replay, wrong origin/certificate, missing user verification, invalid signatures and wrong ownership. |
| +- Live ocho.chat Chromium virtual PRF authenticator test passed signup, encrypted DMs, enrollment, clearing local storage, discoverable sign-in, restored identical keys/message history and signed-in reload. Live WebKit passed signup and recovery-file restoration without leaving ocho.chat. |
| +- Signed iPhone/macOS builds pass. Physical Apple/Android passkey provider ceremonies are still unverified and require user presence; virtual authenticator results do not establish that compatibility. |
| ## Hosted state |
| -Account ocho / bdy; apps infinity and infinity-home; channel canary/infinity. Both apps report build 2017ba3d from their own version handlers. The registration domain is ocho.chat; its backing route is infinity-home-test.bdy.oncarlos.com. Existing processes needed app-scoped restarts after promotion to load the new binaries. Initial concurrent uploads failed reading their second binary; sequential HTTP/1.1 retries shipped successfully. |
| +Both Carlos apps (`infinity-home` and `infinity`, account `ocho`, channel `canary/infinity`) serve Go-engine-era release `4d5be3db`, verified from response bodies, with the stable-origin relay and web passkeys. Android certificate association is implemented and tested locally; its deployment is the next release step. Its certificate configuration pins the actual local debug APK, not a future store signing identity. |
| -DNSimple context eleven authenticates the Eleven Messenger team (account 176940). The ocho.chat zone is active with four apex A records: 99.81.104.219, 54.252.186.195, 16.60.78.128 and 44.210.206.26, TTL 300. All four endpoints passed certificate-validated HTTPS checks. The initial certificate challenge failed before cross-edge alias propagation completed; it subsequently converged. Registration INFINITY_ORIGIN and personal INFINITY_HOME_ORIGIN both point to https://ocho.chat. |
| +DNSimple team `eleven` (176940) serves ocho.chat. DNS and certificate-validated HTTPS are verified. Earlier Carlos release-adoption permission failures were fixed in platform b08445b on Ireland; no tenant database/WAL was reset. App deployments upload binaries sequentially and require response-body checks because a new version header can precede process adoption. |
| -A real signup exposed a platform permission bug after the app release had already been staged: a new instance could not create its live symlink under the sandbox. A new app release initially unblocked the waiting user instance without claiming or resetting it. The permanent fix, originally 90b0878, was independently reviewed, integrated and merged through Amadan as b08445b994a63e79e62aade2823166cb88514fa5. Both branch and main passed the full platform gate, five cross-compilation targets and MinIO contract. |
| +## Limits and follow-ups |
| -The Ireland host now runs platform b08445b. Its running executable hash was verified, services/relay paths were active, and new signup logs demonstrated the privileged adoption relay completing for already-staged releases. The rollout targeted Ireland only. The prior 66587fa binary was captured and verified for rollback; no tenant database or WAL was reset/deleted. No console deployment was needed. |
| +This is an early text-chat implementation with no independent security audit or double-ratchet forward secrecy. Servers see routing metadata, timing, sizes, public keys and delivery capabilities. Removal takes effect when a sender receives the group update; authenticated older epochs remain readable. Recovery/passkeys clone an account identity; independent device revocation is not implemented. Web clients trust code served by their origin. |
| -Earlier disk pressure was resolved by external cleanup, freeing roughly 9 GB; this session did not expand the volume or delete shared data. The original Eleven baseline and the implementation branch are pushed to https://amadan.net/eleven/ocho under the authorized Codex agent identity. |
| +Universal links, native background push, attachments, voice/video, distribution signing and store releases remain follow-ups. Amadan CI is not configured; validation above ran explicitly. Implementation is pushed on isolated branch `infinity-v1` at `https://amadan.net/eleven/ocho`; main and the original Eleven checkout remain unmerged. |
| -## Explicit limits |
| - |
| -This is an early text-chat implementation, not an independently audited security product. Recipient-key encryption has no double-ratchet forward secrecy. Group removal takes effect at a sender when it receives the membership update; old epochs remain valid for authenticated history, with no global cutoff for delayed/backdated old-epoch messages. Web clients trust code served by their origin. Routing metadata, timing, sizes, public keys and outbound delivery capabilities are server-visible. Recovery clones the identity across devices; independent device revocation and passkey-based recovery remain future work. |
| - |
| -Apple apps share the tested web UI; universal links, background push and App Store signing are not configured. Attachments, voice and video are not implemented. Amadan CI is not yet configured for the Ocho fork; local and hosted validation above ran explicitly. |
| - |
| -Source work is on infinity-v1 in the isolated .worktrees/infinity checkout. The root checkout preserves the Eleven fork baseline. No Ocho implementation merge to main has been performed. |
| - |
| -## Setup interlude β 2026-09-06 (Codex) |
| - |
| -Account creation replaces the form with a dedicated βTo infinity. And your people.β screen. An indeterminate light travels along a 0β8 meter toward β. Messages follow actual device checking, allocation, startup and opening; there are no invented percentages. Reduced-motion preferences stop the animation. Errors restore the existing form, name and invitation with the same registration nonce. |
| - |
| -The setup interlude is live in both apps at 2017ba3d. Go tests/vet, Chromium/WebKit flows and nine crypto/protocol checks passed. Desktop, mobile, dark-mode and reduced-motion previews were inspected. A fresh deployed WebKit signup verified the dedicated screen, indeterminate meter, available live announcements and successful personal-account handoff. |
| - |
| -## Progress animation refinement β 2026-09-06 (Codex) |
| - |
| -At Paulβs request, infinity sits farther beyond the bar and the fill advances slowly during setup instead of looping. This is an estimated visual cue, with no numerical percentage presented; it remains below the end until readiness is confirmed. The start time travels with the existing setup fragment to keep motion continuous across the account handoff. Successful personal setup triggers an accelerating 800 ms burst beyond the bar to infinity before opening chat. Reduced motion skips that animation. Chromium/WebKit checks verified increasing fill, the pending cap, extra spacing, completion overshoot and no mobile overflow. |
| - |
| -Both apps now serve f4d3acb8, verified from their /api/version response bodies. A fresh deployed WebKit signup passed the complete account handoff, observed the readiness-only completion state and then opened chat without browser errors. Go tests/vet, Chromium/WebKit flows and all nine crypto/protocol checks passed. The local Mac app was opened for Paul. |
| - |
| -## Native/passkey rewrite in progress β 2026-09-06 (Codex) |
| - |
| -Paul superseded the wrapper approach: native SwiftUI on both platforms, CryptoKit and URLSession in a shared OchoCore package, no WebKit/JavaScript runtime in either app. The replacement currently compiles for Mac and iPhone. Bidirectional Web Crypto/CryptoKit envelopes, vaults, recovery files and PRF wrapping derivation pass. Three actual personal processes pass native DMs, groups, consent, removal and restored history; projection tests reject forged membership and missing epochs. The real iPhone UI test is still being run. |
| - |
| -The front door now has a bounded known-tenant API relay plus Rastrillo WebAuthn registration/assertion verification and a public associated-domain file. Additive tables store only public credential metadata, routing association, short-lived challenges and PRF-sealed identities. Origin/RP/verification/signature/replay/ownership tests pass. An isolated Chromium virtual authenticator with PRF passed signup, enrollment, clearing local storage, discoverable sign-in, restoration of the same private identity and reload while remaining on the front door. This is not yet evidence of a physical Apple passkey ceremony. Changes are not deployed or installed yet; do not confuse these local results with the existing production wrapper release. |
| - |
| -### Shared Go engine and native UI verification |
| - |
| -Paul added Android/shared-engine scope. The shipping native architecture now uses a Go/gomobile engine (`mobile/`) for crypto, verified event projection, serialized account state, networking and registration. Swift keeps native UI, Keychain, system passkey prompts and thin value/binding adapters. There is no independent Swift crypto/protocol implementation and no WebView/JavaScript runtime. Apple XCFramework generation succeeds; Android AAR generation will reuse the Eleven sessionβs in-progress SDK/NDK installation. |
| - |
| -Seven native acceptance tests pass through the Go bindings, including bidirectional Web Crypto compatibility for envelopes/vaults/recovery/PRF wrapping, three real personal processes, forged group membership and legacy identity migration. The actual iPhone simulator UI test passed again after the Go switch: receive, navigate, compose, send, confirm peer receipt and assert zero WebViews. The full existing Chromium/WebKit chat suite passes. Extended front-door tests pass encrypted history restoration through a discoverable PRF passkey and WebKit recovery without leaving the public origin. Physical Apple passkey interaction remains unverified; no claim of production deployment or installation yet. |
| +Machine tooling and cross-session delivery limits are recorded in `android-handoff.md`. Build/test commands are in `apple/README.md` and `android/README.md`. |
diff --git a/internal/infinityhome/android.go b/internal/infinityhome/android.go| new file mode 100644 |
| index 0000000..eee88bf |
| --- /dev/null |
| +++ b/internal/infinityhome/android.go |
| @@ -0,0 +1,42 @@ |
| +// π€ Android passkeys accept only the configured signing certificate and app association. |
| +package infinityhome |
| + |
| +import ( |
| + "amadan.net/rastrillo/rastrillo/webauthn" |
| + "encoding/hex" |
| + "encoding/json" |
| + "net/http" |
| + "strings" |
| +) |
| + |
| +func (h *Home) androidCertificate() []byte { |
| + b, e := hex.DecodeString(strings.ReplaceAll(h.cfg.AndroidCertSHA256, ":", "")) |
| + if e != nil || len(b) != 32 { |
| + return nil |
| + } |
| + return b |
| +} |
| +func (h *Home) ceremonyConfig(clientData string) webauthn.Config { |
| + config := h.webauthnConfig() |
| + var data struct { |
| + Origin string `json:"origin"` |
| + } |
| + if json.Unmarshal(decoded(clientData), &data) == nil { |
| + if cert := h.androidCertificate(); cert != nil && data.Origin == "android:apk-key-hash:"+encoded(cert) { |
| + config.Origin = data.Origin |
| + } |
| + } |
| + return config |
| +} |
| +func (h *Home) androidAssociation(w http.ResponseWriter, r *http.Request) { |
| + entries := []any{} |
| + if cert := h.androidCertificate(); cert != nil { |
| + text := strings.ToUpper(hex.EncodeToString(cert)) |
| + pairs := []string{} |
| + for i := 0; i < len(text); i += 2 { |
| + pairs = append(pairs, text[i:i+2]) |
| + } |
| + entries = append(entries, map[string]any{"relation": []string{"delegate_permission/common.get_login_creds"}, "target": map[string]any{"namespace": "android_app", "package_name": "chat.ocho.app", "sha256_cert_fingerprints": []string{strings.Join(pairs, ":")}}}) |
| + } |
| + write(w, 200, entries) |
| +} |
diff --git a/internal/infinityhome/home.go b/internal/infinityhome/home.go| index 9076f80..7f09766 100644 |
| --- a/internal/infinityhome/home.go |
| +++ b/internal/infinityhome/home.go |
| @@ -31,6 +31,7 @@ import ( |
| var schema embed.FS |
| type Config struct { |
| + AndroidCertSHA256 string |
| Origin, Console, Account, App, Domain, Channel, Token string |
| ClaimKey ed25519.PrivateKey |
| Client *http.Client |
| @@ -73,6 +74,7 @@ func New(d *db.DB, cfg Config) (*Home, error) { |
| func (h *Home) Handler() *http.ServeMux { |
| m := infinity.WebHandler() |
| h.passkeyRoutes(m) |
| + m.HandleFunc("GET /.well-known/assetlinks.json", h.androidAssociation) |
| m.HandleFunc("GET /.well-known/apple-app-site-association", appleAssociation) |
| for _, method := range []string{"GET", "POST", "PUT"} { |
| m.Handle(method+" /api/tenant/{host}/{path...}", csrf.Protect(h.cfg.Origin)(http.HandlerFunc(h.relay))) |
diff --git a/internal/infinityhome/passkeys.go b/internal/infinityhome/passkeys.go| index 764c0fa..eda5782 100644 |
| --- a/internal/infinityhome/passkeys.go |
| +++ b/internal/infinityhome/passkeys.go |
| @@ -165,7 +165,7 @@ func (h *Home) passkeyRegisterFinish(w http.ResponseWriter, r *http.Request) { |
| failure(w, 400, "Invalid encrypted account.") |
| return |
| } |
| - cred, e := h.webauthnConfig().Register(decoded(p.Challenge), decoded(p.ClientData), decoded(p.Attestation)) |
| + cred, e := h.ceremonyConfig(p.ClientData).Register(decoded(p.Challenge), decoded(p.ClientData), decoded(p.Attestation)) |
| if e != nil { |
| failure(w, 403, "Could not verify this passkey.") |
| return |
| @@ -206,7 +206,7 @@ func (h *Home) passkeyLoginFinish(w http.ResponseWriter, r *http.Request) { |
| failure(w, 403, "Could not verify this passkey.") |
| return |
| } |
| - next, e := h.webauthnConfig().Verify(webauthn.Credential{ID: decoded(p.ID), PublicKey: key, SignCount: count}, decoded(p.Challenge), decoded(p.ClientData), decoded(p.AuthData), decoded(p.Signature)) |
| + next, e := h.ceremonyConfig(p.ClientData).Verify(webauthn.Credential{ID: decoded(p.ID), PublicKey: key, SignCount: count}, decoded(p.Challenge), decoded(p.ClientData), decoded(p.AuthData), decoded(p.Signature)) |
| if e != nil { |
| failure(w, 403, "Could not verify this passkey.") |
| return |
diff --git a/internal/infinityhome/passkeys_test.go b/internal/infinityhome/passkeys_test.go| index ea3b553..ccb9fa3 100644 |
| --- a/internal/infinityhome/passkeys_test.go |
| +++ b/internal/infinityhome/passkeys_test.go |
| @@ -26,7 +26,7 @@ func TestPasskeyCeremonies(t *testing.T) { |
| } |
| defer d.Close() |
| host := "p-0123456789abcdef01234567.bdy.oncarlos.com" |
| - h, e := New(d, Config{Origin: "https://ocho.chat", Console: "https://console.example", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Token: "scoped", ClaimKey: key, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| + h, e := New(d, Config{AndroidCertSHA256: strings.Repeat("ab", 32), Origin: "https://ocho.chat", Console: "https://console.example", Account: "bdy", App: "infinity", Domain: "bdy.oncarlos.com", Token: "scoped", ClaimKey: key, Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { |
| status := 403 |
| if r.URL.Host == host && r.Header.Get("Authorization") == "Bearer owner" { |
| status = 200 |
| @@ -100,6 +100,11 @@ func TestPasskeyCeremonies(t *testing.T) { |
| bad = options |
| bad.Flags = authtest.FlagUserPresent |
| sign(bad, begin["userHandle"], 403) |
| + android := options |
| + android.Origin = "android:apk-key-hash:" + encoded(h.androidCertificate()) |
| + sign(android, begin["userHandle"], 200) |
| + android.Origin = "android:apk-key-hash:" + encoded([]byte(strings.Repeat("x", 32))) |
| + sign(android, begin["userHandle"], 403) |
| bad = options |
| bad.CorruptSig = true |
| sign(bad, begin["userHandle"], 403) |
diff --git a/test/infinity/home-browser.mjs b/test/infinity/home-browser.mjs| index 4bcd0fc..d16e899 100644 |
| --- a/test/infinity/home-browser.mjs |
| +++ b/test/infinity/home-browser.mjs |
| @@ -4,8 +4,10 @@ import {spawn} from 'node:child_process'; |
| import assert from 'node:assert/strict'; |
| const require=createRequire(new URL('./package.json',import.meta.url)); |
| const {chromium,webkit}=require('playwright'); |
| -const home='http://localhost:18860'; |
| -const server=spawn('.build/native-check/home-test',['-test.run','^TestHomeBrowserHarness$','-test.timeout','5m'],{env:{...process.env,OCHO_HOME_BROWSER:'1'},stdio:['ignore','pipe','pipe']}); |
| +const home=process.env.OCHO_HOME_URL || 'http://localhost:18860'; |
| +const live=process.env.OCHO_LIVE==='1'; |
| +if (!live && home!=='http://localhost:18860') throw Error('Remote verification requires OCHO_LIVE=1'); |
| +const server=live?null:spawn('.build/native-check/home-test',['-test.run','^TestHomeBrowserHarness$','-test.timeout','5m'],{env:{...process.env,OCHO_HOME_BROWSER:'1'},stdio:['ignore','pipe','pipe']}); |
| let browser; |
| try { |
| for(let n=0;;n++){try{if((await fetch(home+'/api/profile')).ok)break;}catch{}if(n>100)throw Error('home harness failed');await new Promise(r=>setTimeout(r,100));} |
| @@ -19,7 +21,7 @@ try { |
| await page.getByRole('button',{name:'Invite',exact:true}).waitFor({timeout:90000}); |
| assert.equal(new URL(page.url()).origin,home); |
| const identity=await page.evaluate(()=>JSON.parse(localStorage.getItem('infinity.identity.v1'))); |
| - assert.ok(identity.origin.endsWith('.example.test')); |
| + assert.ok(identity.origin.endsWith(live?'.bdy.oncarlos.com':'.example.test')); |
| // Put real encrypted history in the account before testing key recovery. |
| const otherContext=await browser.newContext(), other=await otherContext.newPage(); |
| await other.goto(home);await other.getByLabel('Your name',{exact:true}).fill('Bob'); |
| @@ -59,4 +61,4 @@ try { |
| console.log('WebKit: stable-origin signup and recovery-file restoration passed.'); |
| } finally {await safari.close();} |
| -} finally {if(browser)await browser.close();server.kill('SIGTERM');} |
| +} finally {if(browser)await browser.close();server?.kill('SIGTERM');} |
diff --git a/tools/infinity/check-android b/tools/infinity/check-android| new file mode 100755 |
| index 0000000..de48d22 |
| --- /dev/null |
| +++ b/tools/infinity/check-android |
| @@ -0,0 +1,20 @@ |
| +#!/bin/sh |
| +# π€ Run the real Compose UI against disposable accounts; select a dedicated emulator explicitly. |
| +set -eu |
| +cd "$(dirname "$0")/../.." |
| +: "${ANDROID_HOME:?Set ANDROID_HOME to an installed Android SDK}" |
| +: "${ANDROID_SERIAL:?Set ANDROID_SERIAL to a dedicated test emulator}" |
| +case "$ANDROID_SERIAL" in emulator-*) ;; *) echo "Use a disposable emulator, not a person's phone." >&2; exit 1;; esac |
| +tools/infinity/build-mobile android |
| +mkdir -p .build/native-check |
| +CGO_ENABLED=0 go build -o .build/native-check/infinity ./cmd/infinity |
| +android_check_dir=$(mktemp -d) |
| +first_pid= second_pid= |
| +trap 'kill ${first_pid:-} ${second_pid:-} 2>/dev/null || true; rm -rf "$android_check_dir"' EXIT |
| +INFINITY_SETUP_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa INFINITY_ALLOW_LOCAL=1 .build/native-check/infinity --addr 127.0.0.1:18881 --db "$android_check_dir/a.db" > "$android_check_dir/a.log" 2>&1 & |
| +first_pid=$! |
| +INFINITY_SETUP_TOKEN=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa INFINITY_ALLOW_LOCAL=1 .build/native-check/infinity --addr 127.0.0.1:18882 --db "$android_check_dir/b.db" > "$android_check_dir/b.log" 2>&1 & |
| +second_pid=$! |
| +"$ANDROID_HOME/platform-tools/adb" reverse tcp:18881 tcp:18881 |
| +"$ANDROID_HOME/platform-tools/adb" reverse tcp:18882 tcp:18882 |
| +android/gradlew -p android connectedDebugAndroidTest |