Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions core/appsettings/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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<RegisteredDevice>

/**
* 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<Unit>

/**
* 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<AppSettingsSeed>
}
Original file line number Diff line number Diff line change
@@ -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<RegisteredDevice> = 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<Unit> =
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<AppSettingsSeed> =
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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading