diff --git a/android/README.md b/android/README.md index 1f96e84f..f866a878 100644 --- a/android/README.md +++ b/android/README.md @@ -13,7 +13,7 @@ independent from the web app; shares only the protocol contract |---|---|---| | `:core:protocol` | **pure Kotlin/JVM** (no Android) | Hub wire types (kotlinx.serialization), chat pipeline port (normalize → reduce → tool groups), message-window/pagination logic, versioned patch application, modes catalog, git output parsers, `BindLink` pairing-link parsing. **M1a landed**: `wire/` (`HapiJson`, `Session`/`SessionPatch`/`SessionSummary`, `DecryptedMessage`, `AgentState`, `Machine`, 13-type `SyncEvent` union via `SyncEvents.parse`, `MessagesResponse`), `catalog/` (flavors + permission/collaboration modes), `patch/SessionPatching.kt` (exact port of `web/src/lib/sessionPatch.ts`), all fixture-verified. | | `:core:data` | Android library | Transport + persistence. **M1b landed** — `auth/` (`JwtPeek`, `CredentialStore` interface + `EncryptedPrefsCredentialStore`/in-memory, `HubUrls` origin normalization, `HubRegistry` roster behind a storage seam, `AuthInterceptor` + single-flight `TokenAuthenticator` with `ensureFreshToken()` and terminal `AuthEvents`), `api/` (`HapiApi` — plain OkHttp + kotlinx.serialization, one suspend fun per v1 endpoint incl. generated-image bytes via a 256 MB OkHttp cache and the multipart transcription helper; `ApiError` with `(status, code)`), `HubSession` per-hub factory; MockWebServer-tested. **M1c landed** — `sse/`: `SseEngine` (per-key `global`/`session:` loops, `connection-changed` handshake gate with `ok`/`gap` resume verdict, per-key `Last-Event-ID` cursors advanced only after downstream hand-off (at-least-once), 10 s connect deadline, 90 s watchdog, 1 s→30 s→300 s backoff + jitter, background retry deferral + 45 s foreground stale check, one silent 401 re-auth per cycle), `OkHttpSseTransport` (dedicated client, `readTimeout=0`, incremental gzip decoding pinned by test, `acceptEncodingIdentity` fallback), `SyncEventRouter` → `SyncTargets` seam; virtual-time tested. Still to come: StateFlow stores + AtomicFile JSON snapshots (M2), FCM registration + WorkManager workers (M4). | -| `:app` | Android application | Compose UI, navigation, deep links (`hapicompanion://bind`), FCM service (M4), hand-rolled DI (`AppGraph`, no Hilt). | +| `:app` | Android application | Compose UI, navigation, deep links (`hapicompanion://bind`), FCM service (M4), hand-rolled DI (`AppGraph`, no Hilt). **M1d landed** — `di/` (`AppGraph` process singletons: Preferences DataStore-backed `HubRegistryStorage`, `EncryptedPrefsCredentialStore`, `HubRegistry`, auth-terminal fan-out; `HubGraph` per active hub: `HubSession` + `SseEngine` wired to `ensureFreshToken`, recreated on hub switch; `LocalAppGraph` CompositionLocal + `viewModelFactory` helper), `feature/pairing/` (landing / zxing `ScanContract` QR scan / manual entry sharing one `PairingViewModel`: health + protocol check → `POST /api/auth` → persist + activate), `feature/home/` placeholder (hub switcher + sign-out), `Navigation.kt` (pairing ⇄ home, auth-terminal → pairing with banner), bind deep-link handling in `MainActivity`. | Dependency direction: `:app` → `:core:data` → `:core:protocol`. @@ -57,6 +57,44 @@ only the needed projects: CI (`.github/workflows/android.yml`) runs the protocol tests and `:app:assembleDebug` on every PR touching `android/**` or `shared/fixtures/**`. +## Pairing + +HAPI is self-hosted: the app talks to a hub **you** run. Pairing = giving the +app a hub URL plus that hub's access token; the app verifies the hub +(`GET /health`, protocol version), exchanges the token for a JWT +(`POST /api/auth`), stores the credentials in `EncryptedSharedPreferences` +(keyed per hub — multiple hubs can be paired, one active at a time), and +lands on the session UI. Three entry points: + +1. **QR scan** — the hub prints two QR codes when started with `--relay` + (also under web Settings → Companion pairing). The in-app scanner accepts + both: the companion deeplink (`hapicompanion://bind?hub=…&code=…`) and the + web direct-access URL (`…?hub=…&token=…`). +2. **Deep link** — scanning the companion QR with the system camera opens the + app directly with a confirm screen (`hapicompanion://bind` intent filter). +3. **Manual entry** — hub URL + access token, for hubs started without + `--relay`. + +### Pairing against a local dev hub + +```sh +# repo root: start the hub (prints the access token + QR codes) +bun run dev + +# emulator: the host machine is 10.0.2.2 +# Hub URL: http://10.0.2.2:3006 +# Access token: from the hub terminal / hub settings.json (CLI_API_TOKEN) +# physical device: use the machine's LAN IP, e.g. http://192.168.1.10:3006 +adb shell am start -a android.intent.action.VIEW \ + -d "hapicompanion://bind?hub=http%3A%2F%2F10.0.2.2%3A3006&code=" # optional: exercises the deep link +``` + +Plain-`http` LAN/emulator hubs work in all build types: the manifest opts in +to cleartext traffic (`android:usesCleartextTraffic="true"`), because +self-hosted LAN hubs are the primary pairing target and Android cannot scope +the exemption to local addresses only. Sign-out (home → Sign out) deletes the +stored credentials for that hub and drops it from the roster. + ## Milestones (track B of the native-clients plan) - **M0** — this scaffold: modules, version catalog, CI, placeholder screen. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c56f6359..f7e7b567 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,6 +54,14 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.json) + + // QR pairing (ScanContract activity-result API; no Play Services). + implementation(libs.zxing.android.embedded) // Markdown rendering (B-M2d1). commonmark comes through :core:protocol's // `api` too; declared here because ui/markdown walks the AST types directly. @@ -67,4 +75,9 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) + + testImplementation(libs.junit) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 359d0d7f..8c673f26 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,11 +1,26 @@ + + + + + + android:theme="@style/Theme.HapiCompanion" + android:usesCleartextTraffic="true"> + + + diff --git a/android/app/src/main/kotlin/app/hapi/companion/AppGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/AppGraph.kt deleted file mode 100644 index cc445280..00000000 --- a/android/app/src/main/kotlin/app/hapi/companion/AppGraph.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.hapi.companion - -/** - * Hand-rolled dependency graph seed (no Hilt/Dagger by design -- the app wires - * roughly 15 long-lived types, which does not justify a DI framework). - * - * From M1 on this becomes a class instantiated in `HapiApplication.onCreate()` - * that owns the singletons in construction order: - * CredentialStore -> AuthManager -> HapiApi -> SseEngine -> stores - * (session list / session detail / message window) -> push registration. - * Compose reads it via a CompositionLocal; workers/services reach it through - * the Application instance. Everything behind it stays constructor-injected - * and unit-testable without this object. - */ -object AppGraph diff --git a/android/app/src/main/kotlin/app/hapi/companion/HapiApp.kt b/android/app/src/main/kotlin/app/hapi/companion/HapiApp.kt new file mode 100644 index 00000000..51c7bfaa --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/HapiApp.kt @@ -0,0 +1,22 @@ +package app.hapi.companion + +import android.app.Application +import app.hapi.companion.di.AppGraph + +/** + * Owns the process-singleton [AppGraph]. Compose reads it through + * [app.hapi.companion.di.LocalAppGraph]; non-Compose entry points (future FCM + * service, WorkManager workers) reach it via `(context.applicationContext as + * HapiApp).appGraph`. + */ +class HapiApp : Application() { + + lateinit var appGraph: AppGraph + private set + + override fun onCreate() { + super.onCreate() + appGraph = AppGraph(this) + appGraph.start() + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt index 3633f306..9db2dd1f 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/MainActivity.kt @@ -1,69 +1,58 @@ package app.hapi.companion +import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.CompositionLocalProvider +import app.hapi.companion.di.AppGraph +import app.hapi.companion.di.LocalAppGraph import app.hapi.companion.ui.theme.HapiTheme -import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION +import app.hapi.protocol.pairing.BindLink /** - * Single-activity entry point. M1d replaces the placeholder with the pairing - * flow (QR scan + `hapicompanion://bind` deep link handling -- the intent - * filter is already declared in the manifest) and Navigation Compose. + * Single-activity entry point (`launchMode="singleTask"`). Hosts the + * Navigation Compose graph and feeds `hapicompanion://bind?hub=…&code=…` + * deep links — cold start and [onNewIntent] — into + * [AppGraph.pendingBindLink]; all parsing stays in [BindLink]. */ class MainActivity : ComponentActivity() { + + private val appGraph: AppGraph get() = (application as HapiApp).appGraph + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + if (savedInstanceState == null) { + // Only a fresh launch consumes the launching intent — after a + // config change / process restore the same (already-consumed) + // intent is redelivered and must not resurrect the confirm card. + handleBindIntent(intent) + } setContent { HapiTheme { - PlaceholderScreen() + CompositionLocalProvider(LocalAppGraph provides appGraph) { + HapiNavigation() + } } } } -} -@Composable -private fun PlaceholderScreen() { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - text = "HAPI", - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Companion scaffold · protocol v$SUPPORTED_PROTOCOL_VERSION", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleBindIntent(intent) + } + + private fun handleBindIntent(intent: Intent?) { + if (intent?.action != Intent.ACTION_VIEW) return + val data = intent.data ?: return + val link = BindLink.parse(data.toString()) + if (link != null) { + appGraph.pendingBindLink.value = link + } else if (BindLink.SCHEME.equals(data.scheme, ignoreCase = true)) { + // Ours but malformed (truncated QR, mangled copy/paste). + appGraph.pairingNotice.value = getString(R.string.pairing_invalid_link) } } } - -@Preview(showBackground = true) -@Composable -private fun PlaceholderScreenPreview() { - HapiTheme { - PlaceholderScreen() - } -} diff --git a/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt new file mode 100644 index 00000000..38163c0a --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/Navigation.kt @@ -0,0 +1,221 @@ +package app.hapi.companion + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation +import androidx.navigation.compose.rememberNavController +import app.hapi.companion.di.LocalAppGraph +import app.hapi.companion.di.viewModelFactory +import app.hapi.companion.feature.home.HomePlaceholderScreen +import app.hapi.companion.feature.pairing.ManualEntryScreen +import app.hapi.companion.feature.pairing.PairingScreen +import app.hapi.companion.feature.pairing.PairingUiState +import app.hapi.companion.feature.pairing.PairingViewModel +import app.hapi.companion.feature.pairing.QrScanScreen +import app.hapi.data.auth.AuthTerminalReason +import kotlinx.coroutines.launch + +object Routes { + const val HOME = "home" + + /** Nested pairing graph (landing ⇄ scan ⇄ manual share one ViewModel). */ + const val PAIRING = "pairing" + const val PAIRING_LANDING = "pairing/landing" + const val PAIRING_SCAN = "pairing/scan" + const val PAIRING_MANUAL = "pairing/manual" +} + +/** + * Root navigation: `pairing` (start when no active hub) ⇄ `home`. Reacts to + * the graph's cross-cutting flows — terminal auth events and active-hub + * removal route back to pairing (with an explanatory banner), pending + * `hapicompanion://bind` deep links route to the pairing confirm card. + */ +@Composable +fun HapiNavigation() { + val graph = LocalAppGraph.current + val ready by graph.ready.collectAsState() + if (!ready) { + // Sub-frame gap while the persisted hub roster loads. + Surface(modifier = Modifier.fillMaxSize()) {} + return + } + + val navController = rememberNavController() + val registryState by graph.hubRegistry.state.collectAsState() + val startDestination = remember { + if (graph.hubRegistry.activeHubUrl == null) Routes.PAIRING else Routes.HOME + } + + // Silent re-auth gave up for good: back to pairing, with the reason. + LaunchedEffect(navController) { + graph.authTerminals.collect { terminal -> + if (terminal.hubUrl == graph.hubRegistry.activeHubUrl) { + graph.pairingNotice.value = terminalNotice(terminal.reason) + navController.navigateClearingBackStack(Routes.PAIRING) + } + } + } + + // A bind deep link arrived (cold start or onNewIntent): surface the + // pairing screen; the landing destination consumes the link itself. + val pendingBind by graph.pendingBindLink.collectAsState() + LaunchedEffect(pendingBind) { + if (pendingBind != null) { + navController.navigateClearingBackStack(Routes.PAIRING) + } + } + + // Last hub signed out (or roster wiped): nothing to show but pairing. + LaunchedEffect(registryState.activeHubUrl) { + if (registryState.activeHubUrl == null) { + val onPairing = navController.currentDestination + ?.hierarchy?.any { it.route == Routes.PAIRING } == true + if (!onPairing) { + navController.navigateClearingBackStack(Routes.PAIRING) + } + } + } + + NavHost(navController = navController, startDestination = startDestination) { + composable(Routes.HOME) { + val activeHubUrl = registryState.activeHubUrl ?: return@composable + val scope = rememberCoroutineScope() + // TODO(M2b): replace HomePlaceholder with SessionListScreen. + HomePlaceholderScreen( + activeHubUrl = activeHubUrl, + pairedHubs = registryState.hubs, + onSwitchHub = { hub -> scope.launch { graph.hubRegistry.setActiveHub(hub) } }, + onPairAnotherHub = { navController.navigate(Routes.PAIRING) }, + onSignOut = { scope.launch { graph.signOut(activeHubUrl) } }, + ) + } + + navigation(startDestination = Routes.PAIRING_LANDING, route = Routes.PAIRING) { + composable(Routes.PAIRING_LANDING) { entry -> + val viewModel = pairingViewModel(navController, entry) + val state by viewModel.state.collectAsState() + val prefill by viewModel.prefill.collectAsState() + val notice by graph.pairingNotice.collectAsState() + + // Consume the deep link into the shared pairing ViewModel. + LaunchedEffect(pendingBind) { + pendingBind?.let { link -> + viewModel.prefillFromLink(link) + graph.pendingBindLink.value = null + } + } + NavigateHomeOnSuccess(navController, state) + + PairingScreen( + state = state, + prefill = prefill, + notice = notice, + onDismissNotice = { graph.pairingNotice.value = null }, + onScanQr = { navController.navigate(Routes.PAIRING_SCAN) }, + onManualEntry = { navController.navigate(Routes.PAIRING_MANUAL) }, + onPairPrefill = viewModel::pairFromPrefill, + onSwitchToPrefilledHub = viewModel::switchToPrefilledHub, + onDismissPrefill = viewModel::dismissPrefill, + onDismissError = viewModel::dismissError, + ) + } + + composable(Routes.PAIRING_SCAN) { entry -> + val viewModel = pairingViewModel(navController, entry) + val state by viewModel.state.collectAsState() + NavigateHomeOnSuccess(navController, state) + + QrScanScreen( + state = state, + onPairLink = { link -> viewModel.pair(link.hubUrl, link.accessToken) }, + onManualEntry = { navController.navigate(Routes.PAIRING_MANUAL) }, + onBack = { navController.popBackStack() }, + onDismissError = viewModel::dismissError, + ) + } + + composable(Routes.PAIRING_MANUAL) { entry -> + val viewModel = pairingViewModel(navController, entry) + val state by viewModel.state.collectAsState() + NavigateHomeOnSuccess(navController, state) + + ManualEntryScreen( + state = state, + onPair = viewModel::pair, + onBack = { navController.popBackStack() }, + onDismissError = viewModel::dismissError, + ) + } + } + } +} + +/** + * The one [PairingViewModel], scoped to the pairing nav-graph entry so the + * landing/scan/manual destinations share pairing state and it is cleared as + * soon as the graph pops. + */ +@Composable +private fun pairingViewModel( + navController: NavHostController, + entry: NavBackStackEntry, +): PairingViewModel { + val graph = LocalAppGraph.current + val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.PAIRING) } + return viewModel( + viewModelStoreOwner = parentEntry, + factory = viewModelFactory { + PairingViewModel( + clientFactory = graph.pairingClientFactory, + credentialStore = graph.credentialStore, + registry = graph.hubRegistry, + ) + }, + ) +} + +/** Pairing finished: clear any stale notice and land on home, stack reset. */ +@Composable +private fun NavigateHomeOnSuccess(navController: NavHostController, state: PairingUiState) { + val graph = LocalAppGraph.current + LaunchedEffect(state) { + if (state is PairingUiState.Success) { + graph.pairingNotice.value = null + navController.navigateClearingBackStack(Routes.HOME) + } + } +} + +private fun NavHostController.navigateClearingBackStack(route: String) { + navigate(route) { + popUpTo(0) { inclusive = true } + launchSingleTop = true + } +} + +/** + * Why the app fell back to pairing. Plain English until the M5 i18n pass + * (emitted outside composition, so no `stringResource` here). + */ +private fun terminalNotice(reason: AuthTerminalReason): String = when (reason) { + AuthTerminalReason.ACCESS_TOKEN_REJECTED -> + "This device's pairing was revoked — the hub's access token changed. Pair again to continue." + AuthTerminalReason.RETRY_EXHAUSTED -> + "The hub kept rejecting this device's session. Pair again to continue." + AuthTerminalReason.MISSING_CREDENTIALS -> + "The stored credentials for this hub went missing. Pair again to continue." +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt new file mode 100644 index 00000000..032dc01c --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/AppGraph.kt @@ -0,0 +1,159 @@ +package app.hapi.companion.di + +import android.content.Context +import androidx.datastore.preferences.preferencesDataStore +import app.hapi.companion.feature.pairing.PairingClient +import app.hapi.companion.feature.pairing.PairingClientFactory +import app.hapi.data.api.HapiApi +import app.hapi.data.auth.AuthEvents +import app.hapi.data.auth.AuthTerminalReason +import app.hapi.data.auth.CredentialStore +import app.hapi.data.auth.EncryptedPrefsCredentialStore +import app.hapi.data.auth.HubRegistry +import app.hapi.data.auth.HubRegistryStorage +import app.hapi.protocol.pairing.BindLink +import app.hapi.protocol.wire.AuthResponse +import app.hapi.protocol.wire.HapiJson +import app.hapi.protocol.wire.HubHealthResponse +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient + +/** One terminal auth failure, re-emitted off OkHttp threads as a flow. */ +data class AuthTerminal( + val hubUrl: String, + val reason: AuthTerminalReason, +) + +/** Process-wide Preferences DataStore (hub roster + future app settings). */ +private val Context.hapiDataStore by preferencesDataStore(name = "hapi_prefs") + +/** + * Process-singleton graph, hand-rolled (no Hilt by design — see plan track B). + * Constructed once in [app.hapi.companion.HapiApp]; Compose reads it via + * [LocalAppGraph]; per-active-hub types live in [HubGraph], swapped by this + * class whenever `HubRegistry.state`'s active hub changes. + * + * Call [start] right after construction: it loads the persisted roster + * ([ready] flips true) and then keeps [activeHubGraph] in sync with the + * registry. + */ +class AppGraph(context: Context) { + + private val appContext = context.applicationContext + + /** App-lifetime scope; nothing here is ever torn down before the process. */ + val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** The protocol-configured Json (lenient, ignores unknown keys). */ + val json: Json = HapiJson + + val hubRegistryStorage: HubRegistryStorage = + DataStoreHubRegistryStorage(appContext.hapiDataStore) + + val credentialStore: CredentialStore = EncryptedPrefsCredentialStore(appContext) + + val hubRegistry: HubRegistry = HubRegistry(hubRegistryStorage) + + private val mutableAuthTerminals = MutableSharedFlow(extraBufferCapacity = 16) + + /** + * Terminal auth failures for any hub (re-pair required). Fired by OkHttp + * worker threads via [authEvents]; navigation collects and routes to the + * pairing screen with an explanatory banner. + */ + val authTerminals: SharedFlow = mutableAuthTerminals.asSharedFlow() + + /** The [AuthEvents] sink every [HubSession][app.hapi.data.HubSession] gets. */ + val authEvents: AuthEvents = AuthEvents { hubUrl, reason -> + mutableAuthTerminals.tryEmit(AuthTerminal(hubUrl, reason)) + } + + /** + * The most recent unconsumed `hapicompanion://bind` deep link. + * MainActivity posts (cold start + onNewIntent); the pairing screen + * consumes and clears. + */ + val pendingBindLink = MutableStateFlow(null) + + /** One-line banner for the pairing screen ("signed out because …"). */ + val pairingNotice = MutableStateFlow(null) + + /** + * Bare client for the two pre-pairing endpoints (`GET /health`, + * `POST /api/auth`) — no interceptors: there are no credentials yet. + */ + private val pairingHttpClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + + /** Builds the pairing probe for a candidate hub URL (normalized upstream). */ + val pairingClientFactory: PairingClientFactory = PairingClientFactory { hubUrl -> + val api = HapiApi(hubUrl = hubUrl, client = pairingHttpClient) + object : PairingClient { + override suspend fun health(): HubHealthResponse = api.health() + override suspend fun authenticate(accessToken: String): AuthResponse = + api.authenticate(accessToken) + } + } + + private val mutableActiveHubGraph = MutableStateFlow(null) + + /** Per-active-hub graph; null while unpaired. Recreated on hub switch. */ + val activeHubGraph: StateFlow = mutableActiveHubGraph.asStateFlow() + + private val mutableReady = MutableStateFlow(false) + + /** False until the persisted hub roster is loaded (gate the first frame). */ + val ready: StateFlow = mutableReady.asStateFlow() + + /** Idempotent-enough for the single Application.onCreate call site. */ + fun start() { + scope.launch { + hubRegistry.load() + mutableReady.value = true + hubRegistry.state + .map { it.activeHubUrl } + .distinctUntilChanged() + .collect { activeHubUrl -> swapActiveHub(activeHubUrl) } + } + } + + /** + * Removes [hubUrl]'s pairing: credentials wiped, roster entry dropped + * (the registry auto-activates the next hub, or none). The active + * [HubGraph] swap follows via the registry observer. + */ + suspend fun signOut(hubUrl: String) { + // TODO(M4a): DELETE /api/devices/register first, while the JWT works. + withContext(Dispatchers.IO) { credentialStore.delete(hubUrl) } + hubRegistry.removeHub(hubUrl) + } + + /** Sequential by construction: only the [start] collector calls this. */ + private fun swapActiveHub(activeHubUrl: String?) { + mutableActiveHubGraph.value?.close() + mutableActiveHubGraph.value = activeHubUrl?.let { hubUrl -> + HubGraph( + hubUrl = hubUrl, + credentialStore = credentialStore, + authEvents = authEvents, + cacheDir = appContext.cacheDir, + ) + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/CompositionLocals.kt b/android/app/src/main/kotlin/app/hapi/companion/di/CompositionLocals.kt new file mode 100644 index 00000000..9d43b3fe --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/CompositionLocals.kt @@ -0,0 +1,26 @@ +package app.hapi.companion.di + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider + +/** + * The process singleton graph, provided at the root of the composition by + * MainActivity (from [app.hapi.companion.HapiApp]). `staticCompositionLocalOf` + * because the instance never changes for the life of the process. + */ +val LocalAppGraph = staticCompositionLocalOf { + error("LocalAppGraph is not provided — wrap the composition in MainActivity") +} + +/** + * Minimal factory for hand-wired ViewModels: + * `viewModel(factory = viewModelFactory { PairingViewModel(graph.…) })`. + * The lambda runs once per store scope; [modelClass] is trusted to match + * because each call site constructs exactly the type it requests. + */ +fun viewModelFactory(create: () -> VM): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = create() as T + } diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorage.kt b/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorage.kt new file mode 100644 index 00000000..53a46a24 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorage.kt @@ -0,0 +1,39 @@ +package app.hapi.companion.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import app.hapi.data.auth.HubRegistryStorage +import java.io.IOException +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first + +/** + * [HubRegistryStorage] backed by the app's Preferences DataStore: the + * registry's serialized snapshot lives under one string key. The blob is + * opaque here — `HubRegistry` owns its schema. + * + * A corrupt/unreadable preferences file degrades to "no snapshot" (empty + * roster → pairing screen) instead of crashing; credentials are unaffected + * (they live in the [app.hapi.data.auth.EncryptedPrefsCredentialStore]). + */ +class DataStoreHubRegistryStorage( + private val dataStore: DataStore, +) : HubRegistryStorage { + + override suspend fun read(): String? = + dataStore.data + .catch { error -> if (error is IOException) emit(emptyPreferences()) else throw error } + .first()[KEY] + + override suspend fun write(value: String) { + dataStore.edit { preferences -> preferences[KEY] = value } + } + + companion object { + /** Single-key layout: the registry snapshot JSON. */ + val KEY: Preferences.Key = stringPreferencesKey("hub_registry") + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt b/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt new file mode 100644 index 00000000..74b04be6 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt @@ -0,0 +1,115 @@ +package app.hapi.companion.di + +import app.hapi.data.HubSession +import app.hapi.data.auth.AuthEvents +import app.hapi.data.auth.CredentialStore +import app.hapi.data.sse.OkHttpSseTransport +import app.hapi.data.sse.SseEngine +import app.hapi.data.sse.SseSubscriptionKey +import app.hapi.data.sse.SseTokenProvider +import app.hapi.data.sse.SyncEventRouter +import app.hapi.data.sse.SyncTargets +import app.hapi.protocol.wire.SyncEvent +import java.io.Closeable +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext + +/** + * Everything scoped to the **active** hub. [AppGraph] creates one per + * active-hub change (observing `HubRegistry.state`) and [close]s the previous + * one — nothing here survives a hub switch. + * + * Wiring: [HubSession] (REST + silent re-auth, from `:core:data`) → its + * `ensureFreshToken` adapts to the [SseEngine]'s token provider (SSE + * authenticates only at connect time) → [SyncEventRouter] fans engine events + * out to [SyncTargets], which is still a no-op stub. + */ +class HubGraph( + hubUrl: String, + credentialStore: CredentialStore, + authEvents: AuthEvents, + /** App cache dir; the per-hub generated-image OkHttp cache nests inside. */ + cacheDir: File, +) : Closeable { + + /** Child of nothing on purpose: cancelled explicitly in [close]. */ + val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + val session: HubSession = HubSession( + hubUrl = hubUrl, + credentialStore = credentialStore, + authEvents = authEvents, + imageCacheDir = File(File(cacheDir, "hub-images"), cacheDirNameFor(hubUrl)), + ) + + /** Normalized origin (via [HubSession]'s own normalization). */ + val hubUrl: String get() = session.hubUrl + + val sseEngine: SseEngine = SseEngine( + baseUrl = session.hubUrl, + transport = OkHttpSseTransport(), + tokenProvider = SessionTokenProvider(session, credentialStore), + scope = scope, + ) + + // TODO(M2b): replace with the real store-backed targets (session list / + // message windows) and subscribe the engine's Global key on foreground. + val syncTargets: SyncTargets = NoopSyncTargets + + val syncEventRouter: SyncEventRouter = SyncEventRouter(syncTargets) + + override fun close() { + scope.cancel() + session.close() + } + + private companion object { + /** + * Filesystem-safe per-hub cache directory name. Distinct hubs must + * map to distinct directories (OkHttp caches require exclusive dirs); + * origins differing only in `[^A-Za-z0-9._-]` characters cannot + * collide because those are exactly `://` and `:`. + */ + fun cacheDirNameFor(hubUrl: String): String = + hubUrl.replace(Regex("[^A-Za-z0-9._-]"), "_") + } +} + +/** + * Adapts [HubSession.ensureFreshToken] to the engine's [SseTokenProvider]. + * + * `forceRefresh` (the hub just 401'd the previous token) drops both JWT + * caches — the persisted copy and the authenticator's in-memory one — so + * `ensureFreshToken` has to do a genuine `POST /api/auth` exchange instead of + * returning a token the hub already rejected (rotated jwt-secret, clock skew). + */ +class SessionTokenProvider( + private val session: HubSession, + private val credentialStore: CredentialStore, +) : SseTokenProvider { + + override suspend fun freshToken(forceRefresh: Boolean): String? { + if (forceRefresh) { + withContext(Dispatchers.IO) { + credentialStore.get(session.hubUrl)?.let { credentials -> + credentialStore.set(credentials.copy(jwt = null, jwtObtainedAtMs = null)) + } + } + session.authenticator.clearCachedJwt() + } + return session.ensureFreshToken() + } +} + +/** Stub sink until the M2b stores land; events are dropped on the floor. */ +object NoopSyncTargets : SyncTargets { + override fun onSessionEvent(scope: SseSubscriptionKey, event: SyncEvent) = Unit + override fun onMachineEvent(scope: SseSubscriptionKey, event: SyncEvent.MachineUpdated) = Unit + override fun onMessageEvent(scope: SseSubscriptionKey, event: SyncEvent) = Unit + override fun onToast(event: SyncEvent.Toast) = Unit + override fun requestFullResync(scope: SseSubscriptionKey) = Unit +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomePlaceholderScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomePlaceholderScreen.kt new file mode 100644 index 00000000..83fb159d --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/home/HomePlaceholderScreen.kt @@ -0,0 +1,188 @@ +package app.hapi.companion.feature.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.hapi.companion.R + +/** + * Post-pairing placeholder until the M2b session list: shows the active hub, + * a hub switcher (with a "pair another" entry) and sign-out. + */ +@Composable +fun HomePlaceholderScreen( + activeHubUrl: String, + pairedHubs: List, + onSwitchHub: (String) -> Unit, + onPairAnotherHub: () -> Unit, + onSignOut: () -> Unit, +) { + var showSwitcher by rememberSaveable { mutableStateOf(false) } + var showSignOutConfirm by rememberSaveable { mutableStateOf(false) } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.home_active_hub), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = activeHubUrl, + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { showSwitcher = true }, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.home_switch_hub)) + } + TextButton(onClick = { showSignOutConfirm = true }, modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.home_sign_out), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(48.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.home_sessions_placeholder), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (showSwitcher) { + HubSwitcherDialog( + activeHubUrl = activeHubUrl, + pairedHubs = pairedHubs, + onSwitchHub = { hub -> + showSwitcher = false + if (hub != activeHubUrl) onSwitchHub(hub) + }, + onPairAnotherHub = { + showSwitcher = false + onPairAnotherHub() + }, + onDismiss = { showSwitcher = false }, + ) + } + + if (showSignOutConfirm) { + AlertDialog( + onDismissRequest = { showSignOutConfirm = false }, + title = { Text(stringResource(R.string.home_sign_out)) }, + text = { Text(stringResource(R.string.home_sign_out_message)) }, + confirmButton = { + TextButton( + onClick = { + showSignOutConfirm = false + onSignOut() + }, + ) { + Text( + text = stringResource(R.string.home_sign_out), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton(onClick = { showSignOutConfirm = false }) { + Text(stringResource(R.string.home_cancel)) + } + }, + ) + } +} + +@Composable +private fun HubSwitcherDialog( + activeHubUrl: String, + pairedHubs: List, + onSwitchHub: (String) -> Unit, + onPairAnotherHub: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.home_switch_hub)) }, + text = { + Column { + pairedHubs.forEach { hub -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = hub == activeHubUrl, onClick = { onSwitchHub(hub) }) + Text( + text = hub, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 4.dp), + ) + } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + TextButton(onClick = onPairAnotherHub) { + Text(stringResource(R.string.home_pair_another)) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.home_cancel)) + } + }, + ) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/ManualEntryScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/ManualEntryScreen.kt new file mode 100644 index 00000000..e091705b --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/ManualEntryScreen.kt @@ -0,0 +1,116 @@ +package app.hapi.companion.feature.pairing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import app.hapi.companion.R + +/** + * Manual fallback for `--relay`-less local hubs: hub URL + access token, + * paste-friendly (plain single-line fields, no autocorrect/capitalization, + * the token is not masked — it is a pairing code, not a password). + */ +@Composable +fun ManualEntryScreen( + state: PairingUiState, + onPair: (hubUrl: String, accessToken: String) -> Unit, + onBack: () -> Unit, + onDismissError: () -> Unit, +) { + var hubUrl by rememberSaveable { mutableStateOf("") } + var accessToken by rememberSaveable { mutableStateOf("") } + val validating = state is PairingUiState.Validating + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.pairing_manual_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pairing_manual_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = hubUrl, + onValueChange = { hubUrl = it }, + label = { Text(stringResource(R.string.pairing_hub_url_label)) }, + placeholder = { Text(stringResource(R.string.pairing_hub_url_placeholder)) }, + singleLine = true, + enabled = !validating, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( + value = accessToken, + onValueChange = { accessToken = it }, + label = { Text(stringResource(R.string.pairing_token_label)) }, + placeholder = { Text(stringResource(R.string.pairing_token_placeholder)) }, + singleLine = true, + enabled = !validating, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + ), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(modifier = Modifier.height(24.dp)) + + PairingStatus(state = state, onDismissError = onDismissError) + + Button( + onClick = { onPair(hubUrl, accessToken) }, + enabled = !validating && hubUrl.isNotBlank() && accessToken.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_pair_button)) + } + Spacer(modifier = Modifier.height(8.dp)) + TextButton(onClick = onBack, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_back)) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingClient.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingClient.kt new file mode 100644 index 00000000..56302292 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingClient.kt @@ -0,0 +1,23 @@ +package app.hapi.companion.feature.pairing + +import app.hapi.protocol.wire.AuthResponse +import app.hapi.protocol.wire.HubHealthResponse + +/** + * The two unauthenticated endpoints pairing needs, as a seam so + * [PairingViewModel] is unit-testable without OkHttp. Production adapts + * `HapiApi` (see `AppGraph.pairingClientFactory`); both methods throw + * `ApiError` on non-2xx and `IOException` when the hub is unreachable. + */ +interface PairingClient { + /** `GET /health` — reachability + `protocolVersion` probe. */ + suspend fun health(): HubHealthResponse + + /** `POST /api/auth` — access-token → JWT exchange; 401 = token rejected. */ + suspend fun authenticate(accessToken: String): AuthResponse +} + +/** Builds a [PairingClient] for one candidate hub (normalized origin). */ +fun interface PairingClientFactory { + fun create(hubUrl: String): PairingClient +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingScreen.kt new file mode 100644 index 00000000..0a4b9e49 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingScreen.kt @@ -0,0 +1,223 @@ +package app.hapi.companion.feature.pairing + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import app.hapi.companion.R + +/** + * Pairing landing: explains the self-hosted model, offers Scan QR / Manual + * entry, renders the deep-link confirm card ([prefill]) and the "why am I + * here" [notice] banner (auth-terminal sign-outs, bad links). + */ +@Composable +fun PairingScreen( + state: PairingUiState, + prefill: BindPrefill?, + notice: String?, + onDismissNotice: () -> Unit, + onScanQr: () -> Unit, + onManualEntry: () -> Unit, + onPairPrefill: () -> Unit, + onSwitchToPrefilledHub: () -> Unit, + onDismissPrefill: () -> Unit, + onDismissError: () -> Unit, +) { + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + if (notice != null) { + NoticeCard(text = notice, onDismiss = onDismissNotice) + Spacer(modifier = Modifier.height(16.dp)) + } + + Text( + text = stringResource(R.string.pairing_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.pairing_intro), + style = MaterialTheme.typography.bodyLarge, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pairing_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + + if (prefill != null) { + PrefillConfirmCard( + prefill = prefill, + validating = state is PairingUiState.Validating, + onPair = onPairPrefill, + onSwitch = onSwitchToPrefilledHub, + onDismiss = onDismissPrefill, + ) + Spacer(modifier = Modifier.height(16.dp)) + } + + PairingStatus(state = state, onDismissError = onDismissError) + + Button( + onClick = onScanQr, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_scan_qr)) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onManualEntry, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_manual_entry)) + } + } + } +} + +/** Shared "validating…" / error block used by all three pairing screens. */ +@Composable +internal fun PairingStatus( + state: PairingUiState, + onDismissError: () -> Unit, + modifier: Modifier = Modifier, +) { + when (state) { + is PairingUiState.Validating -> { + Row( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Text( + text = stringResource(R.string.pairing_validating), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + is PairingUiState.Error -> { + Card( + modifier = modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = state.message, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onDismissError, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.pairing_error_dismiss)) + } + } + } + } + else -> Unit + } +} + +@Composable +private fun NoticeCard(text: String, onDismiss: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text(text = text, style = MaterialTheme.typography.bodyMedium) + TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.pairing_notice_dismiss)) + } + } + } +} + +@Composable +private fun PrefillConfirmCard( + prefill: BindPrefill, + validating: Boolean, + onPair: () -> Unit, + onSwitch: () -> Unit, + onDismiss: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.pairing_confirm_title), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = prefill.hubUrl, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary, + ) + if (prefill.alreadyPaired) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.pairing_confirm_already), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + if (prefill.alreadyPaired) { + Button(onClick = onSwitch, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_confirm_switch)) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton(onClick = onPair, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_confirm_repair)) + } + } else { + Button(onClick = onPair, enabled = !validating, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.pairing_confirm_pair)) + } + } + TextButton(onClick = onDismiss, enabled = !validating, modifier = Modifier.align(Alignment.End)) { + Text(stringResource(R.string.pairing_confirm_cancel)) + } + } + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingViewModel.kt new file mode 100644 index 00000000..b518fd86 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PairingViewModel.kt @@ -0,0 +1,205 @@ +package app.hapi.companion.feature.pairing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.hapi.data.api.ApiError +import app.hapi.data.auth.CredentialStore +import app.hapi.data.auth.HubCredentials +import app.hapi.data.auth.HubRegistry +import app.hapi.data.auth.HubUrls +import app.hapi.protocol.pairing.BindLink +import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION +import java.io.IOException +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Pairing progress; one attempt at a time. */ +sealed interface PairingUiState { + data object Idle : PairingUiState + data object Validating : PairingUiState + data class Error(val message: String) : PairingUiState + + /** Credentials stored, hub registered + active — navigate home. */ + data class Success(val hubUrl: String) : PairingUiState +} + +/** A `hapicompanion://bind` deep link waiting for user confirmation. */ +data class BindPrefill( + /** Normalized hub origin. */ + val hubUrl: String, + val accessToken: String, + /** True when this hub is already in the roster (offer switch/re-pair). */ + val alreadyPaired: Boolean, +) + +/** + * Drives one pairing attempt (`docs/api/client-contract/auth.md#pairing`): + * normalize the URL → `GET /health` reachability + protocol check → + * `POST /api/auth` exchange → persist [HubCredentials] → register + activate + * in the [HubRegistry] → [PairingUiState.Success]. + * + * Shared by the landing / QR-scan / manual-entry destinations (scoped to the + * pairing nav graph), so an attempt started from a scan result reports into + * the same state the other screens render. + */ +class PairingViewModel( + private val clientFactory: PairingClientFactory, + private val credentialStore: CredentialStore, + private val registry: HubRegistry, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val nowMs: () -> Long = System::currentTimeMillis, +) : ViewModel() { + + private val mutableState = MutableStateFlow(PairingUiState.Idle) + val state: StateFlow = mutableState.asStateFlow() + + private val mutablePrefill = MutableStateFlow(null) + val prefill: StateFlow = mutablePrefill.asStateFlow() + + /** Loads a deep link into the confirm card (replacing any previous one). */ + fun prefillFromLink(link: BindLink) { + val normalized = HubUrls.normalize(link.hubUrl) + if (normalized == null) { + mutableState.value = PairingUiState.Error(MSG_INVALID_URL) + return + } + mutablePrefill.value = BindPrefill( + hubUrl = normalized, + accessToken = link.accessToken, + alreadyPaired = normalized in registry.state.value.hubs, + ) + } + + fun dismissPrefill() { + mutablePrefill.value = null + } + + /** Confirm the deep link: full pairing (also the "re-pair" choice). */ + fun pairFromPrefill() { + prefill.value?.let { pair(it.hubUrl, it.accessToken) } + } + + /** "Already paired / switch hub" choice: keep credentials, just activate. */ + fun switchToPrefilledHub() { + val target = prefill.value ?: return + viewModelScope.launch { + mutableState.value = if (registry.setActiveHub(target.hubUrl)) { + PairingUiState.Success(target.hubUrl) + } else { + // Roster changed under us (sign-out race): fall back to pairing. + PairingUiState.Error(MSG_HUB_GONE) + } + } + } + + /** Starts a pairing attempt; no-op while one is already validating. */ + fun pair(hubUrl: String, accessToken: String) { + if (mutableState.value == PairingUiState.Validating) return + val normalized = HubUrls.normalize(hubUrl) + if (normalized == null) { + mutableState.value = PairingUiState.Error(MSG_INVALID_URL) + return + } + // Trim whitespace only; the token stays opaque otherwise (never split + // client-side — the `:namespace` suffix belongs to the hub). + val token = accessToken.trim() + if (token.isEmpty()) { + mutableState.value = PairingUiState.Error(MSG_EMPTY_TOKEN) + return + } + mutableState.value = PairingUiState.Validating + viewModelScope.launch { + mutableState.value = runPairing(normalized, token) + } + } + + /** Error → Idle (retry affordance); other states are left alone. */ + fun dismissError() { + if (mutableState.value is PairingUiState.Error) { + mutableState.value = PairingUiState.Idle + } + } + + private suspend fun runPairing(hubUrl: String, accessToken: String): PairingUiState { + val client = clientFactory.create(hubUrl) + + val health = try { + client.health() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: IOException) { + return PairingUiState.Error(msgUnreachable(hubUrl)) + } catch (_: Exception) { + // Non-2xx or a body that is not the health schema: something + // answered, but it does not look like a HAPI hub. + return PairingUiState.Error(msgNotAHub(hubUrl)) + } + if (health.protocolVersion != SUPPORTED_PROTOCOL_VERSION) { + return PairingUiState.Error(msgProtocolMismatch(health.protocolVersion)) + } + + val auth = try { + client.authenticate(accessToken) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: ApiError) { + return if (error.status == 401) { + PairingUiState.Error(MSG_TOKEN_REJECTED) + } else { + PairingUiState.Error(msgAuthFailed(error.status)) + } + } catch (_: Exception) { + return PairingUiState.Error(msgUnreachable(hubUrl)) + } + + // Credentials first, then the roster: the active-hub observer builds a + // HubGraph as soon as the registry flips, and it must find the secret. + withContext(ioDispatcher) { + credentialStore.set( + HubCredentials( + hubUrl = hubUrl, + accessToken = accessToken, + jwt = auth.token, + jwtObtainedAtMs = nowMs(), + ) + ) + } + registry.addHub(hubUrl, makeActive = true) + mutablePrefill.value = null + return PairingUiState.Success(hubUrl) + } + + companion object { + // Plain English for M1; the M5 i18n pass moves user-facing copy to + // resources (these also serve as stable assertions for JVM tests). + const val MSG_INVALID_URL: String = + "Enter a valid hub URL, like http://192.168.1.10:3006 or https://hub.example.com." + const val MSG_EMPTY_TOKEN: String = "Enter the access token shown by your hub." + const val MSG_TOKEN_REJECTED: String = + "The hub rejected this access token. Tokens change when the hub rotates its " + + "CLI_API_TOKEN — grab a fresh QR code or token and try again." + const val MSG_HUB_GONE: String = "That hub is no longer paired. Pair it again below." + + fun msgUnreachable(hubUrl: String): String = + "Could not reach $hubUrl. Check the address, your network, and that the hub is running." + + fun msgNotAHub(hubUrl: String): String = + "$hubUrl answered, but not like a HAPI hub. Double-check the URL (it should be " + + "the hub's own address, not the web app's)." + + fun msgProtocolMismatch(hubProtocolVersion: Int): String = + "This hub speaks protocol v$hubProtocolVersion but the app supports " + + "v$SUPPORTED_PROTOCOL_VERSION. Update the ${ + if (hubProtocolVersion > SUPPORTED_PROTOCOL_VERSION) "app" else "hub" + } and try again." + + fun msgAuthFailed(status: Int): String = + "Pairing failed (HTTP $status). Try again in a moment." + } +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PortraitCaptureActivity.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PortraitCaptureActivity.kt new file mode 100644 index 00000000..34655122 --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/PortraitCaptureActivity.kt @@ -0,0 +1,11 @@ +package app.hapi.companion.feature.pairing + +import com.journeyapps.barcodescanner.CaptureActivity + +/** + * zxing-android-embedded pins its stock `CaptureActivity` to sensorLandscape + * in the library manifest; this empty subclass gets its own manifest entry + * with `screenOrientation="portrait"` so the pairing scan matches the rest of + * the (portrait) flow. Selected via `ScanOptions.setCaptureActivity`. + */ +class PortraitCaptureActivity : CaptureActivity() diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/QrScanScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/QrScanScreen.kt new file mode 100644 index 00000000..16efc92e --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/pairing/QrScanScreen.kt @@ -0,0 +1,184 @@ +package app.hapi.companion.feature.pairing + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import app.hapi.companion.R +import app.hapi.protocol.pairing.BindLink +import app.hapi.protocol.pairing.PairingLinks +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions + +/** + * QR pairing via zxing-android-embedded's [ScanContract] (the full-screen + * capture activity as an Activity Result — simpler than embedding + * `DecoratedBarcodeView`). Entering the screen requests the CAMERA permission + * through the Activity Result API and then launches the scanner; scans are + * parsed with [PairingLinks.parse], so both the companion deeplink QR and the + * hub's web-URL QR pair successfully, and anything else gets a friendly + * "not a pairing code" retry card. + */ +@Composable +fun QrScanScreen( + state: PairingUiState, + onPairLink: (BindLink) -> Unit, + onManualEntry: () -> Unit, + onBack: () -> Unit, + onDismissError: () -> Unit, +) { + val context = LocalContext.current + var notPairingQr by rememberSaveable { mutableStateOf(false) } + var permissionDenied by rememberSaveable { mutableStateOf(false) } + val scanPrompt = stringResource(R.string.pairing_scan_prompt) + + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + val contents = result.contents + if (contents == null) { + // Back/cancel inside the capture activity: leave the scan screen too. + onBack() + } else { + val link = PairingLinks.parse(contents) + if (link == null) notPairingQr = true else onPairLink(link) + } + } + + fun launchScanner() { + notPairingQr = false + scanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt(scanPrompt) + setBeepEnabled(false) + setOrientationLocked(true) + setCaptureActivity(PortraitCaptureActivity::class.java) + } + ) + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + permissionDenied = false + launchScanner() + } else { + permissionDenied = true + } + } + + fun startScan() { + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (granted) launchScanner() else permissionLauncher.launch(Manifest.permission.CAMERA) + } + + // Straight into the scanner on entry — unless something (an error card, + // a denied permission, a running attempt) is already on screen. + LaunchedEffect(Unit) { + if (state is PairingUiState.Idle && !notPairingQr && !permissionDenied) startScan() + } + + Surface(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.pairing_scan_title), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.height(24.dp)) + + PairingStatus(state = state, onDismissError = onDismissError) + + if (notPairingQr) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + ) { + Text( + text = stringResource(R.string.pairing_scan_not_pairing_qr), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp), + ) + } + } + if (permissionDenied) { + Text( + text = stringResource(R.string.pairing_scan_permission_rationale), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 16.dp), + ) + } + + Button( + onClick = ::startScan, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource( + if (permissionDenied) R.string.pairing_scan_grant_camera + else R.string.pairing_scan_again + ) + ) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onManualEntry, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_manual_entry)) + } + TextButton( + onClick = onBack, + enabled = state !is PairingUiState.Validating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.pairing_back)) + } + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index eb93d97c..5da34f82 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -9,4 +9,48 @@ Binary file not shown Show %1$d more lines Collapse + + + Pair with your hub + HAPI is self-hosted: your coding agents run on your own machines behind a HAPI hub you operate. Pair this app with that hub to watch and steer them from your phone. + Scan the “Companion app” QR code your hub prints on startup (with --relay), or enter the hub URL and access token by hand. + Scan QR code + Enter manually + Dismiss + Dismiss + Checking hub… + Pair with this hub? + This hub is already paired on this device. + Pair now + Use existing pairing + Re-pair with the new code + Cancel + That pairing link is invalid or incomplete. + Back + + + Manual pairing + Find both values in the hub terminal on startup, or in the web app under Settings → Companion pairing. + Hub URL + http://192.168.1.10:3006 + Access token + Paste the access token + Pair + + + Scan pairing QR + Point the camera at the pairing QR code + That QR code is not a HAPI pairing code. Scan one of the two QR codes your hub prints (Companion app or Web app). + Scan again + Camera access is needed to scan the pairing QR code. You can also pair manually instead. + Grant camera access + + + Active hub + Your agent sessions will appear here — session list lands in M2b. + Switch hub + Pair another hub… + Sign out + Remove this hub pairing? The stored credentials are deleted from this device (the hub itself is untouched). + Cancel diff --git a/android/app/src/test/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorageTest.kt b/android/app/src/test/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorageTest.kt new file mode 100644 index 00000000..a3cfab27 --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/di/DataStoreHubRegistryStorageTest.kt @@ -0,0 +1,120 @@ +package app.hapi.companion.di + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import app.hapi.data.auth.HubRegistry +import app.hapi.data.auth.HubRegistryState +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.rules.TemporaryFolder + +/** + * JVM tests against a real temp-file Preferences DataStore + * (datastore-preferences-core is pure JVM — no Android runtime needed). + */ +class DataStoreHubRegistryStorageTest { + + @get:Rule + val tmp = TemporaryFolder() + + private fun newDataStoreScope(): CoroutineScope = + CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private fun storeFile(name: String): File = + // DataStore requires the .preferences_pb extension. + File(tmp.root, "$name.preferences_pb") + + @Test + fun `raw blob round-trips and lands on disk`() = runTest { + val scope = newDataStoreScope() + try { + val file = storeFile("raw") + val storage = DataStoreHubRegistryStorage( + PreferenceDataStoreFactory.create(scope = scope) { file } + ) + + assertNull(storage.read()) + + storage.write("""{"hubs":["https://a.example"],"activeHubUrl":"https://a.example"}""") + assertEquals( + """{"hubs":["https://a.example"],"activeHubUrl":"https://a.example"}""", + storage.read(), + ) + assertTrue(file.exists(), "datastore.edit must have flushed to disk") + + storage.write("""{"hubs":[],"activeHubUrl":null}""") + assertEquals("""{"hubs":[],"activeHubUrl":null}""", storage.read()) + } finally { + scope.coroutineContext[Job]?.cancelAndJoin() + } + } + + @Test + fun `a HubRegistry snapshot survives reload through the same store`() = runTest { + val scope = newDataStoreScope() + try { + val storage = DataStoreHubRegistryStorage( + PreferenceDataStoreFactory.create(scope = scope) { storeFile("registry") } + ) + + val first = HubRegistry(storage) + first.load() + first.addHub("https://a.example") + first.addHub("http://b.example:8080", makeActive = false) + + val second = HubRegistry(storage) + second.load() + assertEquals( + HubRegistryState( + hubs = listOf("https://a.example", "http://b.example:8080"), + activeHubUrl = "https://a.example", + ), + second.state.value, + ) + } finally { + scope.coroutineContext[Job]?.cancelAndJoin() + } + } + + @Test + fun `the snapshot survives a cold start - a fresh datastore on the same file`() = runTest { + val file = storeFile("coldstart") + + val firstScope = newDataStoreScope() + try { + val storage = DataStoreHubRegistryStorage( + PreferenceDataStoreFactory.create(scope = firstScope) { file } + ) + val registry = HubRegistry(storage) + registry.load() + registry.addHub("https://hub.example") + } finally { + // Fully release the file so the "second process" may open it. + firstScope.coroutineContext[Job]?.cancelAndJoin() + } + + val secondScope = newDataStoreScope() + try { + val storage = DataStoreHubRegistryStorage( + PreferenceDataStoreFactory.create(scope = secondScope) { file } + ) + val registry = HubRegistry(storage) + registry.load() + assertEquals( + HubRegistryState(hubs = listOf("https://hub.example"), activeHubUrl = "https://hub.example"), + registry.state.value, + ) + } finally { + secondScope.coroutineContext[Job]?.cancelAndJoin() + } + } +} diff --git a/android/app/src/test/kotlin/app/hapi/companion/feature/pairing/PairingViewModelTest.kt b/android/app/src/test/kotlin/app/hapi/companion/feature/pairing/PairingViewModelTest.kt new file mode 100644 index 00000000..2c536486 --- /dev/null +++ b/android/app/src/test/kotlin/app/hapi/companion/feature/pairing/PairingViewModelTest.kt @@ -0,0 +1,224 @@ +// Dispatchers.setMain + advanceUntilIdle are still experimental in coroutines-test. +@file:OptIn(ExperimentalCoroutinesApi::class) + +package app.hapi.companion.feature.pairing + +import app.hapi.data.api.ApiError +import app.hapi.data.auth.HubRegistry +import app.hapi.data.auth.InMemoryCredentialStore +import app.hapi.data.auth.InMemoryHubRegistryStorage +import app.hapi.protocol.pairing.BindLink +import app.hapi.protocol.wire.AuthResponse +import app.hapi.protocol.wire.AuthUser +import app.hapi.protocol.wire.HubHealthResponse +import app.hapi.protocol.wire.SUPPORTED_PROTOCOL_VERSION +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before + +private const val HUB = "http://localhost:3006" + +/** Programmable [PairingClient]: default = healthy v1 hub + accepted token. */ +private class FakePairingClient : PairingClient { + var healthResult: () -> HubHealthResponse = + { HubHealthResponse(status = "ok", protocolVersion = SUPPORTED_PROTOCOL_VERSION) } + var authResult: (String) -> AuthResponse = + { AuthResponse(token = "jwt-1", user = AuthUser(id = 1)) } + + val authCalls = mutableListOf() + + override suspend fun health(): HubHealthResponse = healthResult() + + override suspend fun authenticate(accessToken: String): AuthResponse { + authCalls += accessToken + return authResult(accessToken) + } +} + +class PairingViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val client = FakePairingClient() + private val requestedUrls = mutableListOf() + private val credentialStore = InMemoryCredentialStore() + private val registry = HubRegistry(InMemoryHubRegistryStorage()) + + private fun viewModel() = PairingViewModel( + clientFactory = PairingClientFactory { hubUrl -> + requestedUrls += hubUrl + client + }, + credentialStore = credentialStore, + registry = registry, + ioDispatcher = dispatcher, + nowMs = { 1_234L }, + ) + + @Before + fun setUp() { + kotlinx.coroutines.Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + kotlinx.coroutines.Dispatchers.resetMain() + } + + private fun runVmTest(block: suspend TestScope.() -> Unit) = runTest(dispatcher, testBody = block) + + @Test + fun `success path stores credentials then registers and activates the hub`() = runVmTest { + val vm = viewModel() + + // Sloppy spelling + padded token: normalized origin, trimmed (never split) token. + vm.pair("HTTP://LocalHost:3006/some/path", " tok_9f8:team ") + assertEquals(PairingUiState.Validating, vm.state.value) + + advanceUntilIdle() + + assertEquals(PairingUiState.Success(HUB), vm.state.value) + assertEquals(listOf(HUB), requestedUrls) + assertEquals(listOf("tok_9f8:team"), client.authCalls) + + val credentials = credentialStore.get(HUB) + assertEquals("tok_9f8:team", credentials?.accessToken) + assertEquals("jwt-1", credentials?.jwt) + assertEquals(1_234L, credentials?.jwtObtainedAtMs) + + assertEquals(listOf(HUB), registry.state.value.hubs) + assertEquals(HUB, registry.activeHubUrl) + } + + @Test + fun `unreachable hub surfaces a reachability error and touches nothing`() = runVmTest { + client.healthResult = { throw IOException("connection refused") } + val vm = viewModel() + + vm.pair(HUB, "tok") + advanceUntilIdle() + + assertEquals(PairingUiState.Error(PairingViewModel.msgUnreachable(HUB)), vm.state.value) + assertTrue(client.authCalls.isEmpty()) + assertNull(credentialStore.get(HUB)) + assertNull(registry.activeHubUrl) + } + + @Test + fun `rejected access token maps 401 to the re-pair message`() = runVmTest { + client.authResult = { throw ApiError(status = 401, code = "Invalid access token") } + val vm = viewModel() + + vm.pair(HUB, "rotated-token") + advanceUntilIdle() + + assertEquals(PairingUiState.Error(PairingViewModel.MSG_TOKEN_REJECTED), vm.state.value) + assertNull(credentialStore.get(HUB)) + assertNull(registry.activeHubUrl) + } + + @Test + fun `malformed url errors immediately without any network call`() = runVmTest { + val vm = viewModel() + + vm.pair("not a url", "tok") + + assertEquals(PairingUiState.Error(PairingViewModel.MSG_INVALID_URL), vm.state.value) + vm.dismissError() + assertEquals(PairingUiState.Idle, vm.state.value) + + vm.pair("ftp://hub.example", "tok") + assertEquals(PairingUiState.Error(PairingViewModel.MSG_INVALID_URL), vm.state.value) + + advanceUntilIdle() + assertTrue(requestedUrls.isEmpty()) + } + + @Test + fun `protocol mismatch is rejected before the token exchange`() = runVmTest { + client.healthResult = { HubHealthResponse(status = "ok", protocolVersion = 99) } + val vm = viewModel() + + vm.pair(HUB, "tok") + advanceUntilIdle() + + assertEquals( + PairingUiState.Error(PairingViewModel.msgProtocolMismatch(99)), + vm.state.value, + ) + assertTrue(client.authCalls.isEmpty()) + } + + @Test + fun `hub that answers garbage maps to the not-a-hub message`() = runVmTest { + client.healthResult = { throw ApiError(status = 404, code = null, body = "") } + val vm = viewModel() + + vm.pair(HUB, "tok") + advanceUntilIdle() + + assertEquals(PairingUiState.Error(PairingViewModel.msgNotAHub(HUB)), vm.state.value) + } + + @Test + fun `deep link prefill normalizes and flags an already-paired hub`() = runVmTest { + registry.addHub("https://hub.example") + val vm = viewModel() + + vm.prefillFromLink(BindLink(hubUrl = "https://Hub.Example/", accessToken = "code1")) + assertEquals( + BindPrefill(hubUrl = "https://hub.example", accessToken = "code1", alreadyPaired = true), + vm.prefill.value, + ) + + vm.prefillFromLink(BindLink(hubUrl = "http://other.example:8080", accessToken = "code2")) + assertEquals( + BindPrefill(hubUrl = "http://other.example:8080", accessToken = "code2", alreadyPaired = false), + vm.prefill.value, + ) + } + + @Test + fun `switch choice re-activates the existing pairing without re-exchanging`() = runVmTest { + registry.addHub("https://a.example") + registry.addHub("https://b.example") // active + val vm = viewModel() + + vm.prefillFromLink(BindLink(hubUrl = "https://a.example", accessToken = "code")) + vm.switchToPrefilledHub() + advanceUntilIdle() + + assertEquals(PairingUiState.Success("https://a.example"), vm.state.value) + assertEquals("https://a.example", registry.activeHubUrl) + assertTrue(requestedUrls.isEmpty()) + assertTrue(client.authCalls.isEmpty()) + } + + @Test + fun `re-pairing an existing hub overwrites its credentials`() = runVmTest { + val vm = viewModel() + vm.pair(HUB, "old-token") + advanceUntilIdle() + assertIs(vm.state.value) + + client.authResult = { AuthResponse(token = "jwt-2", user = AuthUser(id = 1)) } + vm.pair(HUB, "new-token") + advanceUntilIdle() + + val credentials = credentialStore.get(HUB) + assertEquals("new-token", credentials?.accessToken) + assertEquals("jwt-2", credentials?.jwt) + assertEquals(listOf(HUB), registry.state.value.hubs) + } +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/BindLink.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/BindLink.kt index dd1c579e..e2f0f357 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/BindLink.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/BindLink.kt @@ -53,9 +53,10 @@ data class BindLink( /** * Decodes an `application/x-www-form-urlencoded` query string. * First occurrence wins for duplicate keys. Returns null if any - * component carries invalid escapes. + * component carries invalid escapes. Internal so [PairingLinks] can + * decode the web direct-access QR with identical semantics. */ - private fun parseFormQuery(rawQuery: String): Map? { + internal fun parseFormQuery(rawQuery: String): Map? { val result = LinkedHashMap() for (pair in rawQuery.split('&')) { if (pair.isEmpty()) continue @@ -79,7 +80,7 @@ data class BindLink( null // unreachable: UTF-8 is always supported } - private fun isHttpUrl(value: String): Boolean { + internal fun isHttpUrl(value: String): Boolean { val uri = try { URI(value) } catch (_: URISyntaxException) { diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/PairingLinks.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/PairingLinks.kt new file mode 100644 index 00000000..2d36643b --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/pairing/PairingLinks.kt @@ -0,0 +1,47 @@ +package app.hapi.protocol.pairing + +import java.net.URI +import java.net.URISyntaxException + +/** + * Scanner-side pairing-link parsing, accepting **both** QR codes the hub + * prints (`hub/src/startHub.ts`, `docs/api/client-contract/auth.md#pairing`): + * + * 1. the companion deeplink `hapicompanion://bind?hub=&code=` + * (canonical for natives — [BindLink.parse]), and + * 2. the web direct-access URL `https://app.hapi.run/?hub=&token=` + * — same credentials under `token=` instead of `code=`. Users will + * inevitably point the in-app scanner at the wrong QR of the pair, so + * [parse] extracts it equivalently as a convenience. + * + * The intent-filter deep-link path stays [BindLink.parse]-only: the OS never + * routes web URLs to the app. + */ +object PairingLinks { + + /** Parses [raw] as a companion deeplink, falling back to the web QR form. */ + fun parse(raw: String): BindLink? = BindLink.parse(raw) ?: parseWebUrl(raw) + + /** + * Parses the web direct-access QR: any absolute http(s) URL whose query + * carries non-blank `hub` (itself an http(s) URL) and `token` params. The + * host is deliberately not pinned to `app.hapi.run` — self-hosters may + * serve the web app from their own origin. Returns null otherwise. + */ + fun parseWebUrl(raw: String): BindLink? { + val uri = try { + URI(raw.trim()) + } catch (_: URISyntaxException) { + return null + } + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme != "http" && scheme != "https") return null + if ((uri.host ?: uri.authority).isNullOrBlank()) return null + + val params = BindLink.parseFormQuery(uri.rawQuery ?: return null) ?: return null + val hub = params["hub"]?.takeIf { it.isNotBlank() } ?: return null + val token = params["token"]?.takeIf { it.isNotBlank() } ?: return null + if (!BindLink.isHttpUrl(hub)) return null + return BindLink(hubUrl = hub, accessToken = token) + } +} diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/pairing/PairingLinksTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/pairing/PairingLinksTest.kt new file mode 100644 index 00000000..51f36620 --- /dev/null +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/pairing/PairingLinksTest.kt @@ -0,0 +1,83 @@ +package app.hapi.protocol.pairing + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PairingLinksTest { + + @Test + fun `parse accepts the companion deeplink form`() { + val link = PairingLinks.parse("hapicompanion://bind?hub=http://192.168.1.20:3006&code=abc123") + + assertEquals(BindLink(hubUrl = "http://192.168.1.20:3006", accessToken = "abc123"), link) + } + + @Test + fun `parse accepts the web direct-access form`() { + // Exactly what the hub prints as the first QR (startHub.ts): + // `${webUrl}/?${new URLSearchParams({hub, token})}`. + val link = PairingLinks.parse( + "https://app.hapi.run/?hub=https%3A%2F%2Fdemo.hapi.run&token=tok_9f8%3Adefault" + ) + + assertEquals("https://demo.hapi.run", link?.hubUrl) + assertEquals("tok_9f8:default", link?.accessToken) + } + + @Test + fun `web form is not pinned to the hosted web origin`() { + val link = PairingLinks.parseWebUrl( + "http://selfhosted.example:8080/?hub=http%3A%2F%2Fhub.example%3A3006&token=x" + ) + + assertEquals("http://hub.example:3006", link?.hubUrl) + assertEquals("x", link?.accessToken) + } + + @Test + fun `web form ignores extra params and keeps first duplicate`() { + val link = PairingLinks.parseWebUrl( + "https://app.hapi.run/?v=1&hub=https%3A%2F%2Fa.example.com&token=first&token=second" + ) + + assertEquals("https://a.example.com", link?.hubUrl) + assertEquals("first", link?.accessToken) + } + + @Test + fun `web form requires both hub and token`() { + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com")) + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?token=abc")) + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=&token=abc")) + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&token=")) + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/")) + } + + @Test + fun `web form rejects a code param - that spelling belongs to the deeplink`() { + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&code=abc")) + } + + @Test + fun `web form requires hub to be an http url`() { + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=ftp%3A%2F%2Fh.example.com&token=x")) + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=not-a-url&token=x")) + } + + @Test + fun `web form rejects non-http schemes and malformed input`() { + assertNull(PairingLinks.parseWebUrl("hapicompanion://bind?hub=https%3A%2F%2Fh.example.com&code=x")) + assertNull(PairingLinks.parseWebUrl("ftp://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&token=x")) + assertNull(PairingLinks.parseWebUrl("")) + assertNull(PairingLinks.parseWebUrl("not a uri at all")) + assertNull(PairingLinks.parseWebUrl("https://app.hapi.run/?hub=https%3A%2F%2Fh.example.com&token=%zz")) + } + + @Test + fun `parse returns null for arbitrary non-pairing content`() { + assertNull(PairingLinks.parse("WIFI:T:WPA;S:mynetwork;P:pass;;")) + assertNull(PairingLinks.parse("https://example.com/some/page")) + assertNull(PairingLinks.parse("hello world")) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 26d7dee3..0b7fa748 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -14,6 +14,14 @@ okhttp = "4.12.0" securityCrypto = "1.1.0" junit = "4.13.2" turbine = "1.2.0" +# Pairing flow (B-M1d). +# datastore 1.1.7 (2025-06) + navigation-compose 2.9.0 (2025-05) + +# lifecycle 2.9.0 (2025-05) are on Google Maven; zxing-android-embedded 4.3.0 +# is the final release on Maven Central (QR scanning without Play Services). +datastore = "1.1.7" +navigationCompose = "2.9.0" +lifecycle = "2.9.0" +zxingEmbedded = "4.3.0" # Markdown / syntax highlighting (B-M2d1). Both verified on Maven Central: # org.commonmark 0.24.0 (latest, 2024-10) + dev.snipme:highlights 1.0.0 (KMP, jvm variant). commonmark = "0.24.0" @@ -24,6 +32,9 @@ highlights = "1.0.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } +androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } # Compose (versions come from the BOM) androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } @@ -42,12 +53,16 @@ highlights = { group = "dev.snipme", name = "highlights", version.ref = "highlig # KotlinX kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } # Networking okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-sse = { group = "com.squareup.okhttp3", name = "okhttp-sse", version.ref = "okhttp" } +# QR scanning (pairing). No Play Services dependency by design (plan track B). +zxing-android-embedded = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxingEmbedded" } + # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }