Handle Android passkey availability and record native rollout
8 files changed,
+32
−6
android/README.md+1 −1android/app/src/main/AndroidManifest.xml+1 −1android/app/src/main/java/chat/ocho/app/MainActivity.kt+2 −0android/app/src/main/java/chat/ocho/app/OchoScreen.kt+1 −1android/app/src/main/java/chat/ocho/app/Passkeys.kt+5 −1android/app/src/main/res/xml/data_extraction_rules.xml+17 −0docs/infinity/android-handoff.md+3 −0docs/infinity/status.md+2 −2
Intent
diff --git a/android/README.md b/android/README.md| index f5175ca..a2a03cc 100644 |
| --- a/android/README.md |
| +++ b/android/README.md |
| @@ -11,7 +11,7 @@ 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. |
| +APK: `android/app/build/outputs/apk/debug/app-debug.apk`. Package: `chat.ocho.app`; minimum API 26 (passkey prompts require API 28 or later). The debug build uses the local Android debug signing key; it is not a store release. |
| ## Verify on a dedicated emulator |
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml| index 54c8ad8..8e67bb5 100644 |
| --- a/android/app/src/main/AndroidManifest.xml |
| +++ b/android/app/src/main/AndroidManifest.xml |
| @@ -1,7 +1,7 @@ |
| <!-- 🤖 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"> |
| + <application android:label="Ocho" android:icon="@drawable/ocho_icon" android:allowBackup="false" android:dataExtractionRules="@xml/data_extraction_rules" 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> |
diff --git a/android/app/src/main/java/chat/ocho/app/MainActivity.kt b/android/app/src/main/java/chat/ocho/app/MainActivity.kt| index 59b8308..25eb885 100644 |
| --- a/android/app/src/main/java/chat/ocho/app/MainActivity.kt |
| +++ b/android/app/src/main/java/chat/ocho/app/MainActivity.kt |
| @@ -2,6 +2,7 @@ |
| package chat.ocho.app |
| import android.content.Intent |
| import android.os.Bundle |
| +import android.os.Build |
| import androidx.activity.ComponentActivity |
| import androidx.activity.compose.setContent |
| import androidx.activity.result.contract.ActivityResultContracts |
| @@ -38,6 +39,7 @@ class MainActivity: ComponentActivity() { |
| 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 { |
| + if (Build.VERSION.SDK_INT < 28) { model.error("Passkeys need Android 9 or later. You can use a recovery file on this device."); return@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.") } |
diff --git a/android/app/src/main/java/chat/ocho/app/OchoScreen.kt b/android/app/src/main/java/chat/ocho/app/OchoScreen.kt| index 4adeb05..3dc8242 100644 |
| --- a/android/app/src/main/java/chat/ocho/app/OchoScreen.kt |
| +++ b/android/app/src/main/java/chat/ocho/app/OchoScreen.kt |
| @@ -84,5 +84,5 @@ import java.util.Date |
| } |
| } |
| } |
| - if (sheet != null) AccountDialogs(sheet!!, model, ui, thread, incoming, onPasskey, onSave) { if (sheet == "connect") onConsumedInvite(); sheet = null } |
| + if (sheet != null) AccountDialogs(sheet!!, model, ui.copy(busy = busy), 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| index 6e00dfb..8f9bbb4 100644 |
| --- a/android/app/src/main/java/chat/ocho/app/Passkeys.kt |
| +++ b/android/app/src/main/java/chat/ocho/app/Passkeys.kt |
| @@ -3,12 +3,15 @@ package chat.ocho.app |
| import android.app.Activity |
| import android.util.Base64 |
| import androidx.credentials.* |
| +import androidx.annotation.RequiresApi |
| +import androidx.credentials.exceptions.NoCredentialException |
| import chat.ocho.engine.ocho.Ocho |
| import kotlinx.coroutines.Dispatchers |
| import kotlinx.coroutines.withContext |
| import org.json.JSONArray |
| import org.json.JSONObject |
| +@RequiresApi(28) |
| 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) |
| @@ -19,7 +22,8 @@ class Passkeys(private val activity: Activity) { |
| 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 response = try { withContext(Dispatchers.Main) { manager.getCredential(activity, GetCredentialRequest(listOf(GetPublicKeyCredentialOption(request.toString())))) } } |
| + catch (_: NoCredentialException) { error("No Ocho passkey was found. Choose another device or use your recovery file.") } |
| val credential = response.credential as? PublicKeyCredential ?: error("Choose an Ocho passkey.") |
| return JSONObject(credential.authenticationResponseJson) |
| } |
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml| new file mode 100644 |
| index 0000000..1f0b67c |
| --- /dev/null |
| +++ b/android/app/src/main/res/xml/data_extraction_rules.xml |
| @@ -0,0 +1,17 @@ |
| +<!-- 🤖 Identities move only through explicit passkey/recovery flows, never OS backup. --> |
| +<data-extraction-rules> |
| + <cloud-backup> |
| + <exclude domain="root" path="."/><exclude domain="file" path="."/> |
| + <exclude domain="database" path="."/><exclude domain="sharedpref" path="."/> |
| + <exclude domain="external" path="."/><exclude domain="device_root" path="."/> |
| + <exclude domain="device_file" path="."/><exclude domain="device_database" path="."/> |
| + <exclude domain="device_sharedpref" path="."/> |
| + </cloud-backup> |
| + <device-transfer> |
| + <exclude domain="root" path="."/><exclude domain="file" path="."/> |
| + <exclude domain="database" path="."/><exclude domain="sharedpref" path="."/> |
| + <exclude domain="external" path="."/><exclude domain="device_root" path="."/> |
| + <exclude domain="device_file" path="."/><exclude domain="device_database" path="."/> |
| + <exclude domain="device_sharedpref" path="."/> |
| + </device-transfer> |
| +</data-extraction-rules> |
diff --git a/docs/infinity/android-handoff.md b/docs/infinity/android-handoff.md| index 668332c..25d11cf 100644 |
| --- a/docs/infinity/android-handoff.md |
| +++ b/docs/infinity/android-handoff.md |
| @@ -29,3 +29,6 @@ The Eleven Android session is `01a0788c-520f-76c2-9edf-4a3d2ee19721`. Its active |
| 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. |
| + |
| + |
| +Final verification: the Android Compose test passed encrypted receipt/send and peer decryption through the Go AAR, Keystore persistence and history after activity recreation, with no WebViews. Final APK build/lint pass. The debug signing association is live at `https://ocho.chat/.well-known/assetlinks.json` in backend release `7b90ff8c`; Apple/web use the same RP. Physical Android passkey provider verification is still outstanding. The APK is not a Play Store release. |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 7deb2a9..ebdeab1 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -4,7 +4,7 @@ |
| 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. |
| -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. |
| +Native SwiftUI iPhone and Mac apps replace the WebKit wrappers. The final signed iPhone build (0.2.0, build 3) was installed and launched on Paul's paired iPhone. The signed Mac app is installed at `~/Applications/Ocho.app`. 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. |
| 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. |
| @@ -25,7 +25,7 @@ The Carlos front door relays only enumerated APIs to known provisioned tenants. |
| ## Hosted state |
| -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. |
| +Both Carlos apps (`infinity-home` and `infinity`, account `ocho`, channel `canary/infinity`) serve release `7b90ff8c`, verified from their response bodies after scoped process restarts. The stable-origin relay, web passkeys and Apple/Android public associations are live at ocho.chat. The Android association pins the actual local debug APK certificate, not a future store signing identity. The personal deploy watcher timed out while its process still served the previous binary; a later app-scoped restart and direct response-body check verified adoption. Subsequent native-only Android guards and documentation changes do not require a server redeploy. |
| 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. |