diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 326e162..ed2555e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,6 +52,10 @@ dependencies { // opened from the messages, lists and documents surfaces, so it has no // navigation entry of its own here. implementation(project(":core:materialize")) + // Depended on so its Hilt modules join the app component: it registers this device + // under Settings → Applications and contributes the sign-out deregistration. It + // also provides the DeviceLabelProvider `:feature:auth` injects for `sync-token`. + implementation(project(":core:appsettings")) // Features implementation(project(":feature:auth")) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 1b8dc96..e051dc7 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -37,6 +37,7 @@ import com.interlinedlist.android.blog.BlogLauncher import com.interlinedlist.android.blog.BlogLink import com.interlinedlist.android.blog.BlogRoutes import com.interlinedlist.android.blog.ui.BlogSubscriptionRoute +import com.interlinedlist.android.core.appsettings.ui.AppDeviceRegistrationViewModel import com.interlinedlist.android.feature.auth.nav.AuthRoutes import com.interlinedlist.android.feature.auth.nav.authGraph import com.interlinedlist.android.feature.directmessages.navigation.DirectMessagesDestinations @@ -331,6 +332,16 @@ private fun MainShell( val pushRegistration: PushRegistrationViewModel = hiltViewModel() LaunchedEffect(Unit) { pushRegistration.runForSession() } + // Companion-app device registry (the web's Settings → Applications). Entering this + // shell is "just signed in" or "launched signed in", which is exactly when the + // registration should be created or refreshed; a brand-new install also seeds its + // settings from the account's main workstation here. Failures are swallowed inside + // the manager and retried on the next launch, so nothing the user is waiting on + // depends on it. The matching deregistration hangs off the auth module's sign-out + // teardown, so it cannot be skipped by whichever exit the user takes. + val appDeviceRegistration: AppDeviceRegistrationViewModel = hiltViewModel() + LaunchedEffect(Unit) { appDeviceRegistration.registerForSession() } + // Route straight to the launch's destination once, when present: a tapped // notification, or a tapped link (a tag feed) resolved in MainActivity. val launchRoute by rememberUpdatedState(pendingRoute) diff --git a/core/appsettings/build.gradle.kts b/core/appsettings/build.gradle.kts new file mode 100644 index 0000000..f54520b --- /dev/null +++ b/core/appsettings/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.core.appsettings" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + // ApiResult / AppError / DispatcherProvider, plus the two cross-module contracts + // this module implements: SessionTeardownTask and DeviceLabelProvider. + implementation(project(":core:common")) + // The shared authed Retrofit (base URL + bearer interceptor) and safeApiCall. + implementation(project(":core:network")) + + implementation(libs.retrofit.core) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + // ViewModel only (no Compose in this module): the signed-in shell in `:app` drives + // the registration lifecycle through a `hiltViewModel()`, exactly as it drives the + // push-token lifecycle. + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // No Room cache: the device registry is a tiny write-mostly registration, and the + // one piece of state worth keeping (this install's device id) is a single string. + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.truth) + // The repository tests drive a real Retrofit/OkHttp stack against MockWebServer. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.kotlinx.serialization) +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceRegistrationManager.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceRegistrationManager.kt new file mode 100644 index 0000000..615347e --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceRegistrationManager.kt @@ -0,0 +1,112 @@ +package com.interlinedlist.android.core.appsettings + +import com.interlinedlist.android.core.appsettings.data.AppSettingsRepository +import com.interlinedlist.android.core.appsettings.device.AppDeviceStore +import com.interlinedlist.android.core.appsettings.domain.ClientVersions +import com.interlinedlist.android.core.common.device.DeviceLabelProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Owns this install's presence in the account's companion-app device registry — the + * only place that decides WHEN Android registers itself under Settings → Applications + * and when that registration is retired. + * + * Lifecycle: + * - **first sign-in, and every launch while signed in** — [registerForSession], driven + * from the signed-in shell exactly like the push-token registration. Entering the + * shell is precisely "just signed in" or "launched signed in", and the endpoint is + * register-*or-refresh*, so the repeat call is what keeps `lastSeenAt` honest. + * - **brand-new install** — the first successful registration is followed by a + * bootstrap read, which the web documents as seeding a new machine from the account's + * main workstation. Runs at most once per account per install. + * - **sign-out and account deletion** — [deregisterOnSessionEnding], driven from + * `:feature:auth`'s single sign-out path via [AppDeviceSessionTeardown]. + * + * Nothing here is on the sign-in critical path and nothing here throws: every call + * returns [ApiResult], so a failed registration leaves the user signed in and simply + * retries on the next launch. + */ +@Singleton +class AppDeviceRegistrationManager @Inject constructor( + private val repository: AppSettingsRepository, + private val store: AppDeviceStore, + private val deviceLabels: DeviceLabelProvider, + private val clientVersions: ClientVersions, +) { + + /** Serialises registration against a concurrent sign-out teardown. */ + private val mutex = Mutex() + + /** + * True once this process has registered successfully for the current session, so + * re-entering the shell (tab changes, configuration changes) does not re-POST. + * Reset by [deregisterOnSessionEnding] so a subsequent sign-in registers again. + */ + private var registeredThisSession = false + + /** + * Registers this device for the signed-in session and, on a brand-new install, + * seeds it from the account. Safe to call on every entry to the signed-in shell. + */ + suspend fun registerForSession() { + mutex.withLock { + if (registeredThisSession) return + val deviceId = store.deviceId() + val registered = repository.registerDevice( + deviceId = deviceId, + deviceName = deviceLabels.deviceLabel, + appVersion = clientVersions.appVersion, + osVersion = clientVersions.osVersion, + ) + // Offline or rejected: stay unregistered and retry on the next launch. + // Bootstrapping now would ask the registry about a device it has never + // heard of, so it waits too. + if (registered !is ApiResult.Success) return + registeredThisSession = true + bootstrapIfFreshInstall(deviceId) + } + } + + /** + * Retires this device's registration (and, server-side, its device-scoped settings + * document) so the account being left no longer lists a phone it can no longer + * reach, then forgets the account-specific state kept here. Called while the bearer + * token is still persisted — see [AppDeviceSessionTeardown]. + */ + suspend fun deregisterOnSessionEnding() { + mutex.withLock { + // The network call goes FIRST: it is bearer-authed, and the local state is + // what lets a retry find the right device. + repository.deregisterDevice(store.deviceId()) + store.clearAccountState() + registeredThisSession = false + } + } + + /** + * Adopts what the account says a fresh machine should start from. The document is + * stored verbatim in [AppDeviceStore.pendingSeed] and **not** interpreted here: + * applying it to the device's preferences is #78's job, and this is the seam it + * plugs into. + */ + private suspend fun bootstrapIfFreshInstall(deviceId: String) { + if (store.hasBootstrapped) return + when (val result = repository.bootstrap(deviceId)) { + is ApiResult.Success -> { + store.pendingSeed = result.data + store.hasBootstrapped = true + } + is ApiResult.Failure -> { + // 404 == `{ "source": "none" }`: this is the account's first machine, + // so there is nothing to seed from and nothing to ask for again. + // Anything else (offline, 5xx) stays pending for the next launch. + if (result.error is AppError.NotFound) store.hasBootstrapped = true + } + } + } +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceSessionTeardown.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceSessionTeardown.kt new file mode 100644 index 0000000..a0e4216 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceSessionTeardown.kt @@ -0,0 +1,23 @@ +package com.interlinedlist.android.core.appsettings + +import com.interlinedlist.android.core.common.session.SessionTeardownTask +import javax.inject.Inject + +/** + * Hooks the device deregistration into `:feature:auth`'s existing sign-out path rather + * than adding a parallel one. `DefaultAuthRepository.logout()` runs every contributed + * [SessionTeardownTask] while the bearer token is still persisted, and BOTH user-facing + * exits — the Account hub's "Sign out" and account deletion — go through that single + * method, so there is no route out of a session that skips this. It is the same + * mechanism the push-token unregister uses (#46). + * + * Why it matters: a device left registered keeps appearing under Settings → + * Applications for an account that is no longer signed in here, and (once #78 lands) + * keeps a settings document for a phone that will never write to it again. + */ +class AppDeviceSessionTeardown @Inject constructor( + private val registrationManager: AppDeviceRegistrationManager, +) : SessionTeardownTask { + + override suspend fun onSessionEnding() = registrationManager.deregisterOnSessionEnding() +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/AppSettingsRepository.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/AppSettingsRepository.kt new file mode 100644 index 0000000..f23c204 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/AppSettingsRepository.kt @@ -0,0 +1,34 @@ +package com.interlinedlist.android.core.appsettings.data + +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeed +import com.interlinedlist.android.core.appsettings.domain.RegisteredDevice +import com.interlinedlist.android.core.common.result.ApiResult + +/** + * The companion-app device registry as this app uses it. Stateless on purpose: *when* + * to register, bootstrap or deregister is the lifecycle's job (see + * [com.interlinedlist.android.core.appsettings.AppDeviceRegistrationManager]). + */ +interface AppSettingsRepository { + + /** Registers this device, or refreshes an existing registration. */ + suspend fun registerDevice( + deviceId: String, + deviceName: String, + appVersion: String?, + osVersion: String?, + ): ApiResult + + /** + * Retires this device's registration. An already-absent device (404) is reported + * as success: teardown has to be idempotent. + */ + suspend fun deregisterDevice(deviceId: String): ApiResult + + /** + * Resolves what a fresh install should seed from. + * [com.interlinedlist.android.core.common.result.AppError.NotFound] means the + * server answered `{ "source": "none" }` — there is nothing to seed from. + */ + suspend fun bootstrap(deviceId: String): ApiResult +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/DefaultAppSettingsRepository.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/DefaultAppSettingsRepository.kt new file mode 100644 index 0000000..0206598 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/DefaultAppSettingsRepository.kt @@ -0,0 +1,80 @@ +package com.interlinedlist.android.core.appsettings.data + +import com.interlinedlist.android.core.appsettings.data.remote.AppSettingsApi +import com.interlinedlist.android.core.appsettings.data.remote.dto.RegisterDeviceRequest +import com.interlinedlist.android.core.appsettings.data.remote.dto.toDomain +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeed +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeedSource +import com.interlinedlist.android.core.appsettings.domain.CompanionApp +import com.interlinedlist.android.core.appsettings.domain.RegisteredDevice +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import javax.inject.Inject + +/** `/api/user/app-settings/{appKey}/…` over the shared authed Retrofit stack. */ +class DefaultAppSettingsRepository @Inject constructor( + private val api: AppSettingsApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : AppSettingsRepository { + + override suspend fun registerDevice( + deviceId: String, + deviceName: String, + appVersion: String?, + osVersion: String?, + ): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { + api.registerDevice( + appKey = CompanionApp.APP_KEY, + body = RegisterDeviceRequest( + deviceId = deviceId, + deviceName = deviceName, + platform = CompanionApp.PLATFORM, + appVersion = appVersion, + osVersion = osVersion, + // Names this app in the web's Applications list the first time the + // account sees the key; ignored on every later registration. + appDisplayName = CompanionApp.APP_DISPLAY_NAME, + ), + ) + }.map { it.device.toDomain() } + } + + override suspend fun deregisterDevice(deviceId: String): ApiResult = + withContext(dispatchers.io) { + when ( + val result = safeApiCall(json) { + api.deregisterDevice(CompanionApp.APP_KEY, deviceId) + } + ) { + is ApiResult.Success -> ApiResult.Success(Unit) + // "No such device" is the state deregistration is trying to reach, so + // a 404 is success — sign-out must be idempotent (the user may have + // removed this device from the web already). + is ApiResult.Failure -> + if (result.error is AppError.NotFound) ApiResult.Success(Unit) else result + } + } + + override suspend fun bootstrap(deviceId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.bootstrap(CompanionApp.APP_KEY, deviceId) }.map { response -> + AppSettingsSeed( + // An unrecognised provenance still carries usable settings; treat + // it as the shared account document rather than discarding it. + source = AppSettingsSeedSource.fromWire(response.source) + ?: AppSettingsSeedSource.ACCOUNT, + schemaVersion = response.schemaVersion, + settings = response.settings ?: JsonObject(emptyMap()), + sourceDeviceName = response.defaultDeviceName, + ) + } + } +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/remote/AppSettingsApi.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/remote/AppSettingsApi.kt new file mode 100644 index 0000000..8e8346e --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/remote/AppSettingsApi.kt @@ -0,0 +1,52 @@ +package com.interlinedlist.android.core.appsettings.data.remote + +import com.interlinedlist.android.core.appsettings.data.remote.dto.BootstrapResponse +import com.interlinedlist.android.core.appsettings.data.remote.dto.DeregisterDeviceResponse +import com.interlinedlist.android.core.appsettings.data.remote.dto.RegisterDeviceRequest +import com.interlinedlist.android.core.appsettings.data.remote.dto.RegisterDeviceResponse +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * The slice of `/help/api/app-settings` this issue needs: the device registry plus the + * first-run bootstrap read. Built from the shared, already-authenticated Retrofit, so + * every call carries the session bearer token — which is why deregistration must run + * *before* the session is cleared on sign-out. + * + * Deliberately NOT modelled here: the account/device settings `PUT`s (the sync work, + * #78) and the device list/rename/promote calls (the Applications screen, #79). + */ +interface AppSettingsApi { + + /** + * Registers this device, or refreshes an existing registration keyed on + * `deviceId` (name, versions and `lastSeenAt`; the default flag is preserved). + * The first device registered for the app becomes the "main workstation". + */ + @POST("api/user/app-settings/{appKey}/devices") + suspend fun registerDevice( + @Path("appKey") appKey: String, + @Body body: RegisterDeviceRequest, + ): RegisterDeviceResponse + + /** + * Deregisters this device and deletes its device-scoped settings document. + * Returns 404 when the device is already gone, which callers treat as success. + */ + @DELETE("api/user/app-settings/{appKey}/devices/{deviceId}") + suspend fun deregisterDevice( + @Path("appKey") appKey: String, + @Path("deviceId") deviceId: String, + ): DeregisterDeviceResponse + + /** Resolves what a brand-new install should start from. 404 means "nothing". */ + @GET("api/user/app-settings/{appKey}/bootstrap") + suspend fun bootstrap( + @Path("appKey") appKey: String, + @Query("deviceId") deviceId: String, + ): BootstrapResponse +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/remote/dto/AppSettingsDtos.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/remote/dto/AppSettingsDtos.kt new file mode 100644 index 0000000..07db5bc --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/data/remote/dto/AppSettingsDtos.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.core.appsettings.data.remote.dto + +import com.interlinedlist.android.core.appsettings.domain.CompanionApp +import com.interlinedlist.android.core.appsettings.domain.RegisteredDevice +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * Body of `POST /api/user/app-settings/{appKey}/devices`. + * + * `deviceId` + `deviceName` + `platform` are required; `appVersion` / `osVersion` are + * optional and are what the web shows next to the machine. Nulls are omitted by the + * shared Json (`explicitNulls = false`). + * + * [platform] deliberately has **no default**: kotlinx.serialization omits a property + * whose value equals its default, which would strip the required `platform` from the + * body and earn a 400. + */ +@Serializable +data class RegisterDeviceRequest( + val deviceId: String, + val deviceName: String, + val platform: String, + val appVersion: String? = null, + val osVersion: String? = null, + val appDisplayName: String? = null, +) + +/** `{ "device": { … } }` — the registration response (no `hasDeviceSettings` here). */ +@Serializable +data class RegisterDeviceResponse(val device: RegisteredDeviceDto) + +@Serializable +data class RegisteredDeviceDto( + val deviceId: String, + val deviceName: String? = null, + val platform: String? = null, + val isDefault: Boolean = false, + val lastSeenAt: String? = null, + val appVersion: String? = null, + val osVersion: String? = null, +) + +fun RegisteredDeviceDto.toDomain() = RegisteredDevice( + deviceId = deviceId, + deviceName = deviceName.orEmpty(), + platform = platform ?: CompanionApp.PLATFORM, + isDefault = isDefault, + lastSeenAt = lastSeenAt, + appVersion = appVersion, + osVersion = osVersion, +) + +/** `{ "deleted": true, "promotedDeviceId": … }` — the deregistration response. */ +@Serializable +data class DeregisterDeviceResponse( + val deleted: Boolean = false, + val promotedDeviceId: String? = null, +) + +/** + * `GET /api/user/app-settings/{appKey}/bootstrap?deviceId=…`. + * + * The resolved document's fields are merged in at the **top level** next to `source` + * (they are not nested under a `doc` key), and `scope`/`deviceId` describe the + * *source* document rather than the requesting device. A 404 carries + * `{ "source": "none" }`, which `safeApiCall` turns into `AppError.NotFound` — so + * nothing but `source` is ever read from a failure. + */ +@Serializable +data class BootstrapResponse( + val source: String? = null, + val appKey: String? = null, + val scope: String? = null, + val deviceId: String? = null, + val version: Int? = null, + val updatedAt: String? = null, + val schemaVersion: Int = 1, + val settings: JsonObject? = null, + val defaultDeviceId: String? = null, + val defaultDeviceName: String? = null, +) diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/device/AppDeviceStore.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/device/AppDeviceStore.kt new file mode 100644 index 0000000..e18f7e4 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/device/AppDeviceStore.kt @@ -0,0 +1,150 @@ +package com.interlinedlist.android.core.appsettings.device + +import android.content.SharedPreferences +import com.interlinedlist.android.core.appsettings.di.AppDevicePreferences +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeed +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeedSource +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * This install's identity in the companion-app device registry, plus the one-shot + * first-run state that goes with it. + * + * Behind an interface so the registration lifecycle can be unit-tested against an + * in-memory double, following `ThemeSettingsStore` / `LastSeenNotificationStore`. + */ +interface AppDeviceStore { + + /** + * The stable id this install reports as `deviceId`. Created on first access and + * kept for the lifetime of the installation — see [SharedPrefsAppDeviceStore] for + * what it is and, importantly, what it is not. + */ + fun deviceId(): String + + /** + * True once the first-run bootstrap has resolved — either it adopted a seed or + * the server said there was nothing to seed from. Guards against re-seeding a + * device that has since made its own choices. + */ + var hasBootstrapped: Boolean + + /** + * The settings this install adopted at bootstrap, waiting to be applied. + * + * **This is the seam for #78.** The sync work reads it once, applies it to the + * device's preferences, and sets it back to null. #77 deliberately stops here: it + * fetches and persists the document without interpreting a single key of it. + */ + var pendingSeed: AppSettingsSeed? + + /** + * Forgets the state that belonged to the account being signed out of ([pendingSeed] + * and [hasBootstrapped]) while **keeping** [deviceId] — the id identifies the + * phone, not the account, and the registry is already scoped per user. Signing in + * as someone else therefore bootstraps again, from *their* main workstation. + */ + fun clearAccountState() +} + +/** + * SharedPreferences-backed [AppDeviceStore]. + * + * ## What the device id is + * + * A random v4 UUID with an `android-` prefix (e.g. + * `android-3f6c…`), generated once on first use and persisted in plain (unencrypted) + * preferences. It satisfies the server's `deviceId` format + * (`^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$`) and is written with `commit()` rather than + * `apply()` so a process death immediately after generation cannot lose it and strand + * an orphan registration on the account. + * + * ## What it deliberately is not + * + * Not `ANDROID_ID`, not the build serial, not an advertising id, not the IMEI. Google + * Play's user-data policy restricts persistent hardware identifiers, `Build.getSerial()` + * needs `READ_PRIVILEGED_PHONE_STATE` (unavailable to normal apps), and Android's own + * guidance is to use an app-scoped, self-generated id for exactly this purpose. A + * random UUID also cannot correlate this user across apps or accounts, and disappears + * when the app is uninstalled — which is the right lifetime: a reinstall is a new + * machine as far as "which settings should this device start from" is concerned. + * + * It lives outside the encrypted session store on purpose: `SessionStore.clear()` wipes + * that file on every sign-out, and an id that changed on each sign-out would leave a + * trail of dead devices under Settings → Applications. + */ +@Singleton +class SharedPrefsAppDeviceStore @Inject constructor( + @AppDevicePreferences private val prefs: SharedPreferences, + private val json: Json, +) : AppDeviceStore { + + override fun deviceId(): String = synchronized(this) { + prefs.getString(KEY_DEVICE_ID, null)?.takeIf { it.isNotBlank() } + ?: newDeviceId().also { prefs.edit().putString(KEY_DEVICE_ID, it).commit() } + } + + override var hasBootstrapped: Boolean + get() = prefs.getBoolean(KEY_BOOTSTRAPPED, false) + set(value) { + prefs.edit().putBoolean(KEY_BOOTSTRAPPED, value).apply() + } + + override var pendingSeed: AppSettingsSeed? + get() = readSeed() + set(value) { + if (value == null) { + prefs.edit().remove(KEY_SEED_SOURCE).remove(KEY_SEED_SETTINGS) + .remove(KEY_SEED_SCHEMA).remove(KEY_SEED_SOURCE_NAME).apply() + } else { + prefs.edit() + .putString(KEY_SEED_SOURCE, value.source.wire) + .putString(KEY_SEED_SETTINGS, value.settings.toString()) + .putInt(KEY_SEED_SCHEMA, value.schemaVersion) + .putString(KEY_SEED_SOURCE_NAME, value.sourceDeviceName) + .apply() + } + } + + override fun clearAccountState() { + pendingSeed = null + hasBootstrapped = false + } + + private fun readSeed(): AppSettingsSeed? { + val source = AppSettingsSeedSource.fromWire(prefs.getString(KEY_SEED_SOURCE, null)) + ?: return null + val settings = prefs.getString(KEY_SEED_SETTINGS, null) + ?.let { runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() } + ?: JsonObject(emptyMap()) + return AppSettingsSeed( + source = source, + schemaVersion = prefs.getInt(KEY_SEED_SCHEMA, 1), + settings = settings, + sourceDeviceName = prefs.getString(KEY_SEED_SOURCE_NAME, null), + ) + } + + companion object { + /** Plain preferences: the id is not a secret, and must outlive a sign-out. */ + const val PREFS_FILE = "il_app_device.prefs" + + private const val KEY_DEVICE_ID = "device_id" + private const val KEY_BOOTSTRAPPED = "bootstrapped" + private const val KEY_SEED_SOURCE = "seed_source" + private const val KEY_SEED_SETTINGS = "seed_settings" + private const val KEY_SEED_SCHEMA = "seed_schema_version" + private const val KEY_SEED_SOURCE_NAME = "seed_source_device_name" + + /** Prefix kept so a device id is recognisable in a server-side device list. */ + private const val DEVICE_ID_PREFIX = "android-" + + /** `android-` + a random v4 UUID: 44 chars, well inside the server's 8..128. */ + fun newDeviceId(): String = DEVICE_ID_PREFIX + UUID.randomUUID() + } +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/device/BuildDeviceLabelProvider.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/device/BuildDeviceLabelProvider.kt new file mode 100644 index 0000000..0b6171a --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/device/BuildDeviceLabelProvider.kt @@ -0,0 +1,36 @@ +package com.interlinedlist.android.core.appsettings.device + +import android.os.Build +import com.interlinedlist.android.core.common.device.DeviceLabelProvider +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The app's one device label, derived from `Build.MODEL`. + * + * This is the single producer of the string that used to be built inline in + * `DefaultAuthRepository` for `sync-token`'s `deviceLabel`; `:feature:auth` now injects + * [DeviceLabelProvider] instead, so Settings → Sessions and Settings → Applications + * name the same phone identically. The format is unchanged + * (`InterlinedList Android · Pixel 8`) so existing sessions keep reading the same. + */ +@Singleton +class BuildDeviceLabelProvider @Inject constructor() : DeviceLabelProvider { + + override val deviceLabel: String = labelFor(Build.MODEL) + + companion object { + /** The app half of the label, used when the device reports no model. */ + const val APP_NAME = "InterlinedList Android" + + /** + * Builds `" · "`, trimmed to the registry's 120-character limit and + * falling back to the app name alone when the model is missing or blank. + */ + fun labelFor(model: String?): String { + val trimmedModel = model?.trim().orEmpty() + val label = if (trimmedModel.isEmpty()) APP_NAME else "$APP_NAME · $trimmedModel" + return label.take(DeviceLabelProvider.MAX_LENGTH) + } + } +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/di/AppSettingsModule.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/di/AppSettingsModule.kt new file mode 100644 index 0000000..1a6e88c --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/di/AppSettingsModule.kt @@ -0,0 +1,100 @@ +package com.interlinedlist.android.core.appsettings.di + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import com.interlinedlist.android.core.appsettings.AppDeviceSessionTeardown +import com.interlinedlist.android.core.appsettings.data.AppSettingsRepository +import com.interlinedlist.android.core.appsettings.data.DefaultAppSettingsRepository +import com.interlinedlist.android.core.appsettings.data.remote.AppSettingsApi +import com.interlinedlist.android.core.appsettings.device.AppDeviceStore +import com.interlinedlist.android.core.appsettings.device.BuildDeviceLabelProvider +import com.interlinedlist.android.core.appsettings.device.SharedPrefsAppDeviceStore +import com.interlinedlist.android.core.appsettings.domain.ClientVersions +import com.interlinedlist.android.core.common.device.DeviceLabelProvider +import com.interlinedlist.android.core.common.session.SessionTeardownTask +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import retrofit2.Retrofit +import javax.inject.Qualifier +import javax.inject.Singleton + +/** + * Distinguishes this module's plain preferences from the app-wide unqualified + * [SharedPreferences] binding, which is the *encrypted* session file owned by + * `:core:datastore`. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AppDevicePreferences + +/** Binds the companion-app registry collaborators to their implementations. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class AppSettingsBindsModule { + + @Binds + @Singleton + abstract fun bindAppSettingsRepository( + impl: DefaultAppSettingsRepository, + ): AppSettingsRepository + + @Binds + @Singleton + abstract fun bindAppDeviceStore(impl: SharedPrefsAppDeviceStore): AppDeviceStore + + /** + * The one device label in the app. `:feature:auth` injects the interface from + * `:core:common` and gets this implementation, so `sync-token`'s `deviceLabel` and + * the device registry's `deviceName` are the same string. + */ + @Binds + @Singleton + abstract fun bindDeviceLabelProvider(impl: BuildDeviceLabelProvider): DeviceLabelProvider + + /** + * Contributes the deregistration to `:feature:auth`'s sign-out teardown, so signing + * out (or deleting the account) always retires this device's registration — the + * same multibinding the push-token unregister uses. + */ + @Binds + @IntoSet + abstract fun bindAppDeviceSessionTeardown(impl: AppDeviceSessionTeardown): SessionTeardownTask +} + +/** Provides the module-local API, preferences and build-derived client versions. */ +@Module +@InstallIn(SingletonComponent::class) +object AppSettingsDataModule { + + @Provides + @Singleton + fun provideAppSettingsApi(retrofit: Retrofit): AppSettingsApi = + retrofit.create(AppSettingsApi::class.java) + + @Provides + @Singleton + @AppDevicePreferences + fun provideAppDevicePreferences(@ApplicationContext context: Context): SharedPreferences = + context.getSharedPreferences(SharedPrefsAppDeviceStore.PREFS_FILE, Context.MODE_PRIVATE) + + /** + * The installed app's own version (a library module's `BuildConfig` reports the + * library's, not the app's) and the OS release. Both are optional to the registry, + * so an unreadable package info degrades to null rather than failing registration. + */ + @Provides + @Singleton + fun provideClientVersions(@ApplicationContext context: Context): ClientVersions = + ClientVersions( + appVersion = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull(), + osVersion = Build.VERSION.RELEASE?.takeIf { it.isNotBlank() }, + ) +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/AppSettingsSeed.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/AppSettingsSeed.kt new file mode 100644 index 0000000..098ab08 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/AppSettingsSeed.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.core.appsettings.domain + +import kotlinx.serialization.json.JsonObject + +/** Where the settings a fresh install starts from were resolved from. */ +enum class AppSettingsSeedSource(val wire: String) { + /** This device already had its own document (a reinstall over an existing device id). */ + SELF("self"), + + /** The account's "main workstation" — the web's documented first-run behaviour. */ + DEFAULT_DEVICE("default-device"), + + /** The account-scoped (shared) document. */ + ACCOUNT("account"), + ; + + companion object { + fun fromWire(wire: String?): AppSettingsSeedSource? = + entries.firstOrNull { it.wire == wire } + } +} + +/** + * What `GET /api/user/app-settings/{appKey}/bootstrap` resolved for this install. + * + * [settings] is the opaque, client-owned document the server round-trips byte for + * byte; this issue (#77) deliberately does **not** interpret it. It is persisted as + * [com.interlinedlist.android.core.appsettings.device.AppDeviceStore.pendingSeed] and + * left for the settings-sync work (#78) to apply to the device's preferences and then + * clear — that pending value is the seam between the two. + * + * @param sourceDeviceName the "main workstation" the settings came from, when + * [source] is [AppSettingsSeedSource.DEFAULT_DEVICE]; null otherwise. Worth keeping + * so #78 can tell the user *which* machine their new phone was set up from. + */ +data class AppSettingsSeed( + val source: AppSettingsSeedSource, + val schemaVersion: Int, + val settings: JsonObject, + val sourceDeviceName: String? = null, +) diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/ClientVersions.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/ClientVersions.kt new file mode 100644 index 0000000..d5e0d43 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/ClientVersions.kt @@ -0,0 +1,12 @@ +package com.interlinedlist.android.core.appsettings.domain + +/** + * The optional `appVersion` / `osVersion` a registration reports, so the web's + * Applications list can show what this machine is running. Both are nullable: the + * registry accepts a registration without them, and a missing value must never stop + * the device being registered. + */ +data class ClientVersions( + val appVersion: String?, + val osVersion: String?, +) diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/CompanionApp.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/CompanionApp.kt new file mode 100644 index 0000000..0f7df48 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/CompanionApp.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.core.appsettings.domain + +/** + * This app's identity in the account-level companion-app registry + * (`/api/user/app-settings/{appKey}/…`, documented at `/help/api/app-settings`). + * + * ## The `appKey` is `interlinedlist-android`, and it must never change + * + * `appKey` is **free-form**: there is no registration step and no allow-list. The + * server creates the namespace the first time it sees a key, seeding the shared app + * catalog entry from the `appDisplayName` sent on that first device registration + * ("Used only to seed the shared app catalog entry the first time this `appKey` is + * seen; ignored afterward"). The web's own first consumer uses `visual-introspection` + * for the Visual Introspection macOS app, so the convention is a human-readable slug + * naming the application — not a vendor prefix or a UUID. + * + * Because the key *is* the namespace, changing it later would orphan every device + * registration and every settings document already stored under the old one: users + * would see a second, empty "InterlinedList Android" entry under Settings → + * Applications and a fresh phone would seed from nothing. **Treat [APP_KEY] as + * permanent.** It is also verified by a unit test against the server's documented + * format (`^[a-z0-9][a-z0-9-]{0,63}$`). + */ +object CompanionApp { + + /** + * The permanent account-level identity of the InterlinedList Android app. + * Never change this value — see the class KDoc. + */ + const val APP_KEY = "interlinedlist-android" + + /** + * Seeds the shared app catalog entry the very first time [APP_KEY] is seen, which + * is the name the web shows under Settings → Applications. Ignored on every later + * registration. + */ + const val APP_DISPLAY_NAME = "InterlinedList Android" + + /** The registry's platform vocabulary allows exactly one value for this client. */ + const val PLATFORM = "android" +} diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/RegisteredDevice.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/RegisteredDevice.kt new file mode 100644 index 0000000..7b412da --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/domain/RegisteredDevice.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.core.appsettings.domain + +/** + * A machine in the account's device registry for this app, as the server echoes it + * back from a registration. The Applications screen that lists these is #79; this + * type exists so registration can be asserted end to end (the device the server + * stored is the device we asked it to store). + */ +data class RegisteredDevice( + val deviceId: String, + val deviceName: String, + val platform: String, + /** True when this device is the account's "main workstation" for this app. */ + val isDefault: Boolean, + val lastSeenAt: String?, + val appVersion: String?, + val osVersion: String?, +) diff --git a/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/ui/AppDeviceRegistrationViewModel.kt b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/ui/AppDeviceRegistrationViewModel.kt new file mode 100644 index 0000000..5ec7912 --- /dev/null +++ b/core/appsettings/src/main/kotlin/com/interlinedlist/android/core/appsettings/ui/AppDeviceRegistrationViewModel.kt @@ -0,0 +1,24 @@ +package com.interlinedlist.android.core.appsettings.ui + +import androidx.lifecycle.ViewModel +import com.interlinedlist.android.core.appsettings.AppDeviceRegistrationManager +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Thin Compose-facing adapter over the app-scoped [AppDeviceRegistrationManager], so + * the signed-in shell can drive the registration lifecycle without holding DI + * plumbing. Mirrors `PushRegistrationViewModel`; the manager is a singleton, so every + * instance talks to the same state. + */ +@HiltViewModel +class AppDeviceRegistrationViewModel @Inject constructor( + private val registrationManager: AppDeviceRegistrationManager, +) : ViewModel() { + + /** + * Registers this device with the account (and seeds a brand-new install). Never + * throws and never blocks anything the user is waiting on. + */ + suspend fun registerForSession() = registrationManager.registerForSession() +} diff --git a/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceRegistrationManagerTest.kt b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceRegistrationManagerTest.kt new file mode 100644 index 0000000..63e3310 --- /dev/null +++ b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/AppDeviceRegistrationManagerTest.kt @@ -0,0 +1,221 @@ +package com.interlinedlist.android.core.appsettings + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeed +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeedSource +import com.interlinedlist.android.core.appsettings.domain.ClientVersions +import com.interlinedlist.android.core.appsettings.domain.RegisteredDevice +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.common.session.SessionTeardownTask +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Test + +/** + * The registration lifecycle: when Android appears under Settings → Applications, when + * a brand-new install seeds itself from the account, and when the registration is + * retired. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class AppDeviceRegistrationManagerTest { + + private val repository = FakeAppSettingsRepository() + private val store = FakeAppDeviceStore(id = "android-this-phone") + + private fun manager( + versions: ClientVersions = ClientVersions(appVersion = "0.1.0", osVersion = "14"), + ) = AppDeviceRegistrationManager( + repository = repository, + store = store, + deviceLabels = FakeDeviceLabelProvider(), + clientVersions = versions, + ) + + private fun seed(source: AppSettingsSeedSource = AppSettingsSeedSource.DEFAULT_DEVICE) = + AppSettingsSeed( + source = source, + schemaVersion = 1, + settings = buildJsonObject { put("themeMode", "DARK") }, + sourceDeviceName = "Studio Pixel", + ) + + // ---- registration ------------------------------------------------------ + + @Test + fun `signing in registers this device under the shared device label`() = runTest { + manager().registerForSession() + + assertThat(repository.registrations).hasSize(1) + val registration = repository.registrations.single() + assertThat(registration.deviceId).isEqualTo("android-this-phone") + // The SAME label `sync-token` sends, so Sessions and Applications agree. + assertThat(registration.deviceName).isEqualTo("InterlinedList Android · Pixel 8") + assertThat(registration.appVersion).isEqualTo("0.1.0") + assertThat(registration.osVersion).isEqualTo("14") + } + + @Test + fun `re-entering the signed-in shell does not re-register`() = runTest { + val manager = manager() + + manager.registerForSession() + manager.registerForSession() + + assertThat(repository.registrations).hasSize(1) + } + + @Test + fun `a device with no readable versions still registers`() = runTest { + manager(ClientVersions(appVersion = null, osVersion = null)).registerForSession() + + assertThat(repository.registrations.single().appVersion).isNull() + assertThat(repository.registrations).hasSize(1) + } + + @Test + fun `a failed registration never throws and never seeds`() = runTest { + // Sign-in must not depend on this: the shell calls it, not the auth path, and + // an offline phone simply stays unregistered until it is not. + repository.registerResult = ApiResult.Failure(AppError.Network("offline")) + repository.bootstrapResult = ApiResult.Success(seed()) + + manager().registerForSession() + + assertThat(store.hasBootstrapped).isFalse() + // Nothing is seeded from a registry that has never heard of this device. + assertThat(repository.bootstraps).isEmpty() + assertThat(store.pendingSeed).isNull() + } + + @Test + fun `registration is retried on the next launch after a failure`() = runTest { + repository.registerResult = ApiResult.Failure(AppError.Network("offline")) + val manager = manager() + manager.registerForSession() + + repository.registerResult = ApiResult.Success( + RegisteredDevice( + deviceId = "android-this-phone", + deviceName = "InterlinedList Android · Pixel 8", + platform = "android", + isDefault = true, + lastSeenAt = null, + appVersion = null, + osVersion = null, + ), + ) + manager.registerForSession() + + assertThat(repository.registrations).hasSize(2) + } + + // ---- bootstrap --------------------------------------------------------- + + @Test + fun `a brand-new install seeds from the account's main workstation`() = runTest { + repository.bootstrapResult = ApiResult.Success(seed()) + + manager().registerForSession() + + assertThat(repository.bootstraps).containsExactly("android-this-phone") + val adopted = store.pendingSeed + assertThat(adopted?.source).isEqualTo(AppSettingsSeedSource.DEFAULT_DEVICE) + assertThat(adopted?.sourceDeviceName).isEqualTo("Studio Pixel") + // Adopted verbatim: #77 stores the document, #78 applies it. + assertThat(adopted?.settings?.get("themeMode")?.jsonPrimitive?.content).isEqualTo("DARK") + assertThat(store.hasBootstrapped).isTrue() + } + + @Test + fun `the account's first machine has nothing to seed from and never asks again`() = runTest { + // 404 { "source": "none" }. + repository.bootstrapResult = ApiResult.Failure(AppError.NotFound("Not found")) + + manager().registerForSession() + + assertThat(store.hasBootstrapped).isTrue() + assertThat(store.pendingSeed).isNull() + } + + @Test + fun `an install that has already bootstrapped is not re-seeded`() = runTest { + store.hasBootstrapped = true + repository.bootstrapResult = ApiResult.Success(seed()) + + manager().registerForSession() + + assertThat(repository.bootstraps).isEmpty() + assertThat(store.pendingSeed).isNull() + } + + @Test + fun `a bootstrap that fails for any other reason is retried next launch`() = runTest { + repository.bootstrapResult = ApiResult.Failure(AppError.Server("boom")) + + manager().registerForSession() + + assertThat(store.hasBootstrapped).isFalse() + assertThat(store.pendingSeed).isNull() + } + + // ---- sign-out / account deletion --------------------------------------- + + @Test + fun `session teardown deregisters this device and forgets the account state`() = runTest { + repository.bootstrapResult = ApiResult.Success(seed()) + val manager = manager() + manager.registerForSession() + + // Exactly what DefaultAuthRepository.logout() invokes, through the same + // SessionTeardownTask contract it runs for the push-token unregister. + val task: SessionTeardownTask = AppDeviceSessionTeardown(manager) + task.onSessionEnding() + + assertThat(repository.deregistrations).containsExactly("android-this-phone") + assertThat(store.pendingSeed).isNull() + assertThat(store.hasBootstrapped).isFalse() + } + + @Test + fun `signing in again after signing out registers again`() = runTest { + val manager = manager() + manager.registerForSession() + AppDeviceSessionTeardown(manager).onSessionEnding() + + manager.registerForSession() + + assertThat(repository.registrations).hasSize(2) + // Same phone, same id — the registry is scoped per user, so reusing it is right. + assertThat(repository.registrations.map { it.deviceId }.distinct()) + .containsExactly("android-this-phone") + } + + @Test + fun `teardown of a never-registered install is harmless`() = runTest { + // Account deletion immediately after a first, failed registration. + repository.deregisterResult = ApiResult.Failure(AppError.NotFound("Not found")) + + AppDeviceSessionTeardown(manager()).onSessionEnding() + + assertThat(repository.deregistrations).hasSize(1) + assertThat(store.hasBootstrapped).isFalse() + } + + @Test + fun `a failed deregistration does not stop the sign-out`() = runTest { + repository.deregisterResult = ApiResult.Failure(AppError.Network("offline")) + val manager = manager() + manager.registerForSession() + + // Must not throw: a teardown step that blew up would be swallowed by + // AuthRepository.logout() anyway, but it must not skip the local cleanup. + AppDeviceSessionTeardown(manager).onSessionEnding() + + assertThat(store.hasBootstrapped).isFalse() + assertThat(store.pendingSeed).isNull() + } +} diff --git a/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/TestDoubles.kt b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/TestDoubles.kt new file mode 100644 index 0000000..98101ae --- /dev/null +++ b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/TestDoubles.kt @@ -0,0 +1,164 @@ +package com.interlinedlist.android.core.appsettings + +import android.content.SharedPreferences +import com.interlinedlist.android.core.appsettings.data.AppSettingsRepository +import com.interlinedlist.android.core.appsettings.device.AppDeviceStore +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeed +import com.interlinedlist.android.core.appsettings.domain.RegisteredDevice +import com.interlinedlist.android.core.common.device.DeviceLabelProvider +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.CoroutineDispatcher + +/** DispatcherProvider that runs everything on the supplied test dispatcher. */ +class TestDispatcherProvider(private val dispatcher: CoroutineDispatcher) : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher +} + +/** A fixed label, standing in for the Build.MODEL-derived one. */ +class FakeDeviceLabelProvider( + override val deviceLabel: String = "InterlinedList Android · Pixel 8", +) : DeviceLabelProvider + +/** Records every registry call so the lifecycle can be asserted without a server. */ +class FakeAppSettingsRepository : AppSettingsRepository { + + var registerResult: ApiResult = ApiResult.Success( + RegisteredDevice( + deviceId = "android-fake", + deviceName = "InterlinedList Android · Pixel 8", + platform = "android", + isDefault = true, + lastSeenAt = null, + appVersion = null, + osVersion = null, + ), + ) + var bootstrapResult: ApiResult = + ApiResult.Failure(AppError.NotFound("Not found")) + var deregisterResult: ApiResult = ApiResult.Success(Unit) + + val registrations = mutableListOf() + val bootstraps = mutableListOf() + val deregistrations = mutableListOf() + + data class Registration( + val deviceId: String, + val deviceName: String, + val appVersion: String?, + val osVersion: String?, + ) + + override suspend fun registerDevice( + deviceId: String, + deviceName: String, + appVersion: String?, + osVersion: String?, + ): ApiResult { + registrations += Registration(deviceId, deviceName, appVersion, osVersion) + return registerResult + } + + override suspend fun deregisterDevice(deviceId: String): ApiResult { + deregistrations += deviceId + return deregisterResult + } + + override suspend fun bootstrap(deviceId: String): ApiResult { + bootstraps += deviceId + return bootstrapResult + } +} + +/** In-memory [AppDeviceStore] with the same id-on-first-use behaviour. */ +class FakeAppDeviceStore(private val id: String = "android-fake") : AppDeviceStore { + var generatedIds = 0 + private set + private var storedId: String? = null + + override fun deviceId(): String = storedId ?: id.also { + storedId = it + generatedIds++ + } + + override var hasBootstrapped: Boolean = false + override var pendingSeed: AppSettingsSeed? = null + + override fun clearAccountState() { + pendingSeed = null + hasBootstrapped = false + } +} + +/** Minimal in-memory [SharedPreferences], as used by the auth and messages tests. */ +class InMemorySharedPreferences : SharedPreferences { + private val values = mutableMapOf() + + override fun getString(key: String?, defValue: String?): String? = + (values[key] as? String) ?: defValue + + override fun contains(key: String?): Boolean = values.containsKey(key) + override fun getAll(): MutableMap = values + override fun getInt(key: String?, defValue: Int): Int = (values[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (values[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = + (values[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = + (values[key] as? Boolean) ?: defValue + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + (values[key] as? MutableSet) ?: defValues + + override fun registerOnSharedPreferenceChangeListener( + l: SharedPreferences.OnSharedPreferenceChangeListener?, + ) = Unit + + override fun unregisterOnSharedPreferenceChangeListener( + l: SharedPreferences.OnSharedPreferenceChangeListener?, + ) = Unit + + override fun edit(): SharedPreferences.Editor = Editor() + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private var clear = false + + override fun putString(key: String, value: String?): SharedPreferences.Editor = + apply { pending[key] = value } + override fun putStringSet( + key: String, + values: MutableSet?, + ): SharedPreferences.Editor = apply { pending[key] = values } + override fun putInt(key: String, value: Int): SharedPreferences.Editor = + apply { pending[key] = value } + override fun putLong(key: String, value: Long): SharedPreferences.Editor = + apply { pending[key] = value } + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = + apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = + apply { pending[key] = value } + override fun remove(key: String): SharedPreferences.Editor = + apply { pending[key] = REMOVED } + override fun clear(): SharedPreferences.Editor = apply { clear = true } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clear) values.clear() + pending.forEach { (k, v) -> if (v === REMOVED) values.remove(k) else values[k] = v } + pending.clear() + clear = false + } + } + + companion object { + private val REMOVED = Any() + } +} diff --git a/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/data/DefaultAppSettingsRepositoryTest.kt b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/data/DefaultAppSettingsRepositoryTest.kt new file mode 100644 index 0000000..523dbe3 --- /dev/null +++ b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/data/DefaultAppSettingsRepositoryTest.kt @@ -0,0 +1,224 @@ +package com.interlinedlist.android.core.appsettings.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.appsettings.TestDispatcherProvider +import com.interlinedlist.android.core.appsettings.data.remote.AppSettingsApi +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeedSource +import com.interlinedlist.android.core.appsettings.domain.CompanionApp +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Drives the device registry against a [MockWebServer]. The enqueued bodies are the + * exact payloads the live API returned when these endpoints were probed with a real + * bearer token (see `/help/api/app-settings`). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultAppSettingsRepositoryTest { + + private val dispatcher = StandardTestDispatcher() + + // Mirrors the production Json (see NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private lateinit var server: MockWebServer + private lateinit var repository: DefaultAppSettingsRepository + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(AppSettingsApi::class.java) + repository = DefaultAppSettingsRepository(api, json, TestDispatcherProvider(dispatcher)) + } + + @After + fun tearDown() = server.shutdown() + + // ---- register ---------------------------------------------------------- + + @Test + fun `registerDevice posts the documented body to the app's own appKey`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { "device": { + "deviceId": "android-abc", + "deviceName": "InterlinedList Android · Pixel 8", + "platform": "android", "isDefault": true, + "lastSeenAt": "2026-09-16T21:38:14.346Z", + "appVersion": "0.1.0", "osVersion": "14" + } } + """.trimIndent(), + ), + ) + + val result = repository.registerDevice( + deviceId = "android-abc", + deviceName = "InterlinedList Android · Pixel 8", + appVersion = "0.1.0", + osVersion = "14", + ) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path) + .isEqualTo("/api/user/app-settings/${CompanionApp.APP_KEY}/devices") + val body = request.body.readUtf8() + assertThat(body).contains("\"deviceId\":\"android-abc\"") + assertThat(body).contains("\"deviceName\":\"InterlinedList Android · Pixel 8\"") + // The registry's platform vocabulary; anything else is a 400. + assertThat(body).contains("\"platform\":\"android\"") + assertThat(body).contains("\"appVersion\":\"0.1.0\"") + assertThat(body).contains("\"osVersion\":\"14\"") + // Names the app in the web's Applications list the first time it is seen. + assertThat(body).contains("\"appDisplayName\":\"InterlinedList Android\"") + + // …and the device the server stored is the device we asked it to store. + val device = (result as ApiResult.Success).data + assertThat(device.deviceId).isEqualTo("android-abc") + assertThat(device.deviceName).isEqualTo("InterlinedList Android · Pixel 8") + assertThat(device.isDefault).isTrue() // the first device is the main workstation + } + + @Test + fun `registerDevice omits versions it does not have`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "device": { "deviceId": "android-abc" } }""")) + + repository.registerDevice("android-abc", "Phone", appVersion = null, osVersion = null) + + val body = server.takeRequest().body.readUtf8() + assertThat(body).doesNotContain("appVersion") + assertThat(body).doesNotContain("osVersion") + } + + @Test + fun `registerDevice surfaces a rejected device id`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(400) + .setBody("""{ "error": "Invalid deviceId", "code": "bad_request" }"""), + ) + + val result = repository.registerDevice("short", "Phone", null, null) + + val error = (result as ApiResult.Failure).error + assertThat(error).isInstanceOf(AppError.Unknown::class.java) + assertThat(error.message).isEqualTo("Invalid deviceId") + } + + // ---- deregister -------------------------------------------------------- + + @Test + fun `deregisterDevice deletes this device from the registry`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody("""{ "deleted": true, "promotedDeviceId": "android-other" }"""), + ) + + val result = repository.deregisterDevice("android-abc") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path) + .isEqualTo("/api/user/app-settings/${CompanionApp.APP_KEY}/devices/android-abc") + } + + @Test + fun `deregistering an already removed device still succeeds`() = runTest(dispatcher) { + // The live API 404s for an unknown device. Teardown must be idempotent: the + // user may have removed this phone from the web before signing out here. + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Not found", "code": "not_found" }"""), + ) + + val result = repository.deregisterDevice("android-abc") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + } + + @Test + fun `deregisterDevice surfaces a real failure`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(500).setBody("""{ "error": "boom" }""")) + + val result = repository.deregisterDevice("android-abc") + + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Server::class.java) + } + + // ---- bootstrap --------------------------------------------------------- + + @Test + fun `bootstrap resolves the main workstation's settings for a new machine`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { "source": "default-device", "appKey": "${CompanionApp.APP_KEY}", + "scope": "device", "deviceId": "android-main", "version": 5, + "updatedAt": "2026-09-16T21:38:25.715Z", "schemaVersion": 2, + "settings": { "themeMode": "DARK" }, + "defaultDeviceId": "android-main", "defaultDeviceName": "Studio Pixel" } + """.trimIndent(), + ), + ) + + val result = repository.bootstrap("android-new") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo( + "/api/user/app-settings/${CompanionApp.APP_KEY}/bootstrap?deviceId=android-new", + ) + val seed = (result as ApiResult.Success).data + assertThat(seed.source).isEqualTo(AppSettingsSeedSource.DEFAULT_DEVICE) + assertThat(seed.schemaVersion).isEqualTo(2) + assertThat(seed.sourceDeviceName).isEqualTo("Studio Pixel") + // The document is opaque: it round-trips, it is not interpreted. + assertThat(seed.settings["themeMode"]?.jsonPrimitive?.content).isEqualTo("DARK") + } + + @Test + fun `bootstrap reports nothing to seed from as NotFound`() = runTest(dispatcher) { + // The live API answers 404 { "source": "none" } for the account's first machine. + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "source": "none" }""")) + + val result = repository.bootstrap("android-new") + + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + @Test + fun `an unfamiliar source still yields its settings`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody("""{ "source": "something-new", "settings": { "a": 1 } }"""), + ) + + val seed = (repository.bootstrap("android-new") as ApiResult.Success).data + + assertThat(seed.source).isEqualTo(AppSettingsSeedSource.ACCOUNT) + assertThat(seed.settings).hasSize(1) + } +} diff --git a/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/device/BuildDeviceLabelProviderTest.kt b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/device/BuildDeviceLabelProviderTest.kt new file mode 100644 index 0000000..9f4864e --- /dev/null +++ b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/device/BuildDeviceLabelProviderTest.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.core.appsettings.device + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.device.DeviceLabelProvider +import org.junit.Test + +/** + * The single device label. Its format is deliberately unchanged from the string + * `DefaultAuthRepository` used to build inline, so a phone that was already listed + * under Settings → Sessions keeps the same name there and now matches the name it + * reports to Settings → Applications. + */ +class BuildDeviceLabelProviderTest { + + @Test + fun `the label names the app and the model`() { + assertThat(BuildDeviceLabelProvider.labelFor("Pixel 8")) + .isEqualTo("InterlinedList Android · Pixel 8") + } + + @Test + fun `a missing model degrades to the app name`() { + assertThat(BuildDeviceLabelProvider.labelFor(null)).isEqualTo("InterlinedList Android") + assertThat(BuildDeviceLabelProvider.labelFor(" ")).isEqualTo("InterlinedList Android") + } + + @Test + fun `the label stays inside the registry's 120-character limit`() { + // deviceName is 1..120 characters; a longer one is a 400 that would leave the + // device unregistered. + val label = BuildDeviceLabelProvider.labelFor("M".repeat(300)) + + assertThat(label.length).isEqualTo(DeviceLabelProvider.MAX_LENGTH) + } +} diff --git a/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/device/SharedPrefsAppDeviceStoreTest.kt b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/device/SharedPrefsAppDeviceStoreTest.kt new file mode 100644 index 0000000..077bc4d --- /dev/null +++ b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/device/SharedPrefsAppDeviceStoreTest.kt @@ -0,0 +1,113 @@ +package com.interlinedlist.android.core.appsettings.device + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.appsettings.InMemorySharedPreferences +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeed +import com.interlinedlist.android.core.appsettings.domain.AppSettingsSeedSource +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import org.junit.Test + +/** + * The device identity this install reports. A second store over the same preferences + * stands in for a process restart, which is the property that matters: an id that + * changed per launch would litter the account with dead devices. + */ +class SharedPrefsAppDeviceStoreTest { + + private val json = Json { ignoreUnknownKeys = true } + private val prefs = InMemorySharedPreferences() + + private fun store() = SharedPrefsAppDeviceStore(prefs, json) + + @Test + fun `the device id is generated once and survives a restart`() { + val first = store().deviceId() + + // Same instance, and a brand-new instance over the same file (a cold start). + assertThat(store().deviceId()).isEqualTo(first) + assertThat(SharedPrefsAppDeviceStore(prefs, json).deviceId()).isEqualTo(first) + } + + @Test + fun `the device id matches the format the server validates`() { + val id = store().deviceId() + + // ^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$ — see /help/api/app-settings. + assertThat(id).matches("[A-Za-z0-9][A-Za-z0-9._:-]{7,127}") + assertThat(id).startsWith("android-") + } + + @Test + fun `two installs get different ids`() { + // Random per install, so it cannot correlate users or devices across accounts. + val other = SharedPrefsAppDeviceStore(InMemorySharedPreferences(), json).deviceId() + + assertThat(store().deviceId()).isNotEqualTo(other) + } + + @Test + fun `an empty stored id is regenerated rather than sent`() { + prefs.edit().putString("device_id", "").apply() + + assertThat(store().deviceId()).startsWith("android-") + } + + @Test + fun `the adopted seed round-trips across a restart`() { + val seed = AppSettingsSeed( + source = AppSettingsSeedSource.DEFAULT_DEVICE, + schemaVersion = 3, + settings = buildJsonObject { + put("themeMode", "DARK") + put("addAnotherAfterSaving", true) + }, + sourceDeviceName = "Studio Pixel", + ) + + store().pendingSeed = seed + + val restored = SharedPrefsAppDeviceStore(prefs, json).pendingSeed + assertThat(restored?.source).isEqualTo(AppSettingsSeedSource.DEFAULT_DEVICE) + assertThat(restored?.schemaVersion).isEqualTo(3) + assertThat(restored?.sourceDeviceName).isEqualTo("Studio Pixel") + assertThat(restored?.settings?.get("themeMode")?.jsonPrimitive?.content).isEqualTo("DARK") + } + + @Test + fun `clearing the seed leaves nothing behind for the next account`() { + val subject = store() + subject.pendingSeed = AppSettingsSeed( + source = AppSettingsSeedSource.ACCOUNT, + schemaVersion = 1, + settings = buildJsonObject { put("a", 1) }, + ) + + subject.pendingSeed = null + + assertThat(SharedPrefsAppDeviceStore(prefs, json).pendingSeed).isNull() + } + + @Test + fun `signing out forgets the account state but keeps the device id`() { + val subject = store() + val id = subject.deviceId() + subject.hasBootstrapped = true + subject.pendingSeed = AppSettingsSeed( + source = AppSettingsSeedSource.ACCOUNT, + schemaVersion = 1, + settings = buildJsonObject { put("a", 1) }, + ) + + subject.clearAccountState() + + // The id identifies the phone, not the account: signing in as someone else + // must not strand a second device on the previous account. + assertThat(subject.deviceId()).isEqualTo(id) + // …but the next account gets its own first-run seeding. + assertThat(subject.hasBootstrapped).isFalse() + assertThat(subject.pendingSeed).isNull() + } +} diff --git a/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/domain/CompanionAppTest.kt b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/domain/CompanionAppTest.kt new file mode 100644 index 0000000..9f9a819 --- /dev/null +++ b/core/appsettings/src/test/kotlin/com/interlinedlist/android/core/appsettings/domain/CompanionAppTest.kt @@ -0,0 +1,29 @@ +package com.interlinedlist.android.core.appsettings.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The `appKey` is this app's permanent identity in the account-level registry. + * Changing it would orphan every device registration and settings document already + * stored under the old key, so it is pinned here as well as in [CompanionApp]'s KDoc. + */ +class CompanionAppTest { + + @Test + fun `the appKey is interlinedlist-android and must never change`() { + assertThat(CompanionApp.APP_KEY).isEqualTo("interlinedlist-android") + } + + @Test + fun `the appKey matches the format the server validates`() { + // ^[a-z0-9][a-z0-9-]{0,63}$ — see /help/api/app-settings. + assertThat(CompanionApp.APP_KEY).matches("[a-z0-9][a-z0-9-]{0,63}") + } + + @Test + fun `the platform is one the registry accepts`() { + // macos | ios | android | windows | linux | web | other + assertThat(CompanionApp.PLATFORM).isEqualTo("android") + } +} diff --git a/core/common/src/main/kotlin/com/interlinedlist/android/core/common/device/DeviceLabelProvider.kt b/core/common/src/main/kotlin/com/interlinedlist/android/core/common/device/DeviceLabelProvider.kt new file mode 100644 index 0000000..c1a0eeb --- /dev/null +++ b/core/common/src/main/kotlin/com/interlinedlist/android/core/common/device/DeviceLabelProvider.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.core.common.device + +/** + * The one human-readable name this install reports for itself. + * + * Two independent server-side registries show the user their devices, and they must + * agree or the same phone reads as two different machines: + * - `POST /api/auth/sync-token` sends it as `deviceLabel` → Settings → **Sessions**; + * - `POST /api/user/app-settings/{appKey}/devices` sends it as `deviceName` → + * Settings → **Applications**. + * + * Declared in `:core:common` for the same reason as + * [com.interlinedlist.android.core.common.session.SessionTokenProvider]: the two + * callers live in different modules (`:feature:auth` and `:core:appsettings`) and + * neither may depend on the other, so the contract sits in the module both already + * depend on while the Android implementation is contributed once, elsewhere. + */ +interface DeviceLabelProvider { + + /** + * A label such as `InterlinedList Android · Pixel 8`. Never blank, and never + * longer than [MAX_LENGTH] characters — the device registry rejects a + * `deviceName` outside 1..120 characters with a 400. + */ + val deviceLabel: String + + companion object { + /** The server's `deviceName` limit (1..120 characters, trimmed). */ + const val MAX_LENGTH = 120 + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt index 4b9eadd..158180b 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.auth.data +import com.interlinedlist.android.core.common.device.DeviceLabelProvider import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.database.dao.UserDao @@ -29,6 +30,12 @@ class DefaultAuthRepository @Inject constructor( private val userDao: UserDao, private val json: Json, private val dispatchers: DispatcherProvider, + /** + * The one name this phone reports for itself. Shared with the companion-app device + * registry (`:core:appsettings`) so the same device reads identically in + * Settings → Sessions and Settings → Applications. + */ + private val deviceLabels: DeviceLabelProvider, /** * Steps contributed by other feature modules that must run while the session is * still valid (e.g. unregistering this device's push token). Dagger supplies an @@ -125,7 +132,7 @@ class DefaultAuthRepository @Inject constructor( SyncTokenRequest( email, password, - deviceLabel = "InterlinedList Android · ${android.os.Build.MODEL}", + deviceLabel = deviceLabels.deviceLabel, ), ) } diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt index 506db30..4eb064c 100644 --- a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.auth.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.common.device.DeviceLabelProvider import com.interlinedlist.android.core.common.session.SessionTeardownTask import com.interlinedlist.android.core.datastore.SessionStore import com.interlinedlist.android.core.network.api.InterlinedListApi @@ -56,6 +57,7 @@ class DefaultAuthRepositoryTest { private fun repository( teardownTasks: Set = emptySet(), + deviceLabels: DeviceLabelProvider = FixedDeviceLabelProvider(), ) = DefaultAuthRepository( api = api, authApi = authApi, @@ -63,6 +65,7 @@ class DefaultAuthRepositoryTest { userDao = dao, json = json, dispatchers = TestDispatcherProvider(dispatcher), + deviceLabels = deviceLabels, sessionTeardownTasks = teardownTasks, ) @@ -133,6 +136,23 @@ class DefaultAuthRepositoryTest { assertThat(session.isLoggedIn).isFalse() } + // ---- device label ------------------------------------------------------ + + @Test + fun `sign-in reports the shared device label on sync-token`() = runTest(dispatcher) { + enqueue(200, """{ "token": "il_tok_abc" }""") + enqueue(200, """{ "user": { "id": "u1", "username": "me" } }""") + + repository(deviceLabels = FixedDeviceLabelProvider("InterlinedList Android · Pixel 8")) + .login("me@example.com", "s3cret!!") + + // One label, produced once (DeviceLabelProvider) and reused by the + // companion-app device registry, so Settings → Sessions and Settings → + // Applications name the same phone identically. + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"deviceLabel\":\"InterlinedList Android · Pixel 8\"") + } + // ---- forgot password --------------------------------------------------- @Test @@ -316,6 +336,15 @@ class DefaultAuthRepositoryTest { } } +/** + * The device label `sync-token` reports. In production this is the app-wide + * [DeviceLabelProvider] implementation, shared with the companion-app device registry + * so one phone reads identically in Settings → Sessions and Settings → Applications. + */ +private class FixedDeviceLabelProvider( + override val deviceLabel: String = "InterlinedList Android · Pixel 8", +) : DeviceLabelProvider + /** Records that teardown ran, and what the session looked like at that moment. */ private class RecordingTeardown(private val session: SessionStore) : SessionTeardownTask { var runCount = 0 diff --git a/settings.gradle.kts b/settings.gradle.kts index d679973..8471ead 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -36,6 +36,11 @@ include(":core:datastore") // invoked from :feature:messages, :feature:lists and :feature:documents, so it // cannot live inside any one of them. include(":core:materialize") +// Shared cross-feature capability: the companion-app device registry + settings store +// (`/api/user/app-settings/...`). It owns this install's device identity, which both +// `:feature:auth` (the sign-in device label) and, later, the Applications screen need, +// so it cannot live inside a single feature module. +include(":core:appsettings") // Feature modules (added per roadmap phase) include(":feature:auth")