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: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,10 @@ The message is a lambda, so build strings inline without guarding on build type
logcat { "Plain info-level message" } // defaults to INFO
logcat(LogPriority.DEBUG) { "GraphQL ${operation.name()} START" } // explicit priority
logcat(LogPriority.ERROR, throwable) { "Failed to load X: ${throwable.message}" }
logcat(LogPriority.INFO, tag = DEEP_LINK_STACK_DEBUG_TAG) { "…" } // greppable custom tag
logcat(LogPriority.INFO, tag = SOMETHING_DEBUG_TAG) { "…" } // greppable custom tag
```

Tag is optional — for a one-off log, just omit it (the caller's class name is used). When you want a greppable trace that spans several call sites or files, a shared `const val SOMETHING_DEBUG_TAG = "…"` passed as `tag` everywhere is a handy tool (see `DEEP_LINK_STACK_DEBUG_TAG` usage across `MainActivity`/`BackstackController`). It's a convenience, not a requirement; don't introduce a const for a single isolated log.
Tag is optional — for a one-off log, just omit it (the caller's class name is used). When you want a greppable trace that spans several call sites or files, a shared `const val SOMETHING_DEBUG_TAG = "…"` passed as `tag` everywhere is a handy tool. It's a convenience, not a requirement; don't introduce a const for a single isolated log.

There is also an Apollo overload, `logcat(priority, operationError: ApolloOperationError, tag, message)`, which auto-downgrades to at most `WARN` for unauthenticated errors. Use it when logging a failed `safeExecute`/`safeFlow` result.

Expand Down
45 changes: 43 additions & 2 deletions app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,16 @@ class MainActivity : AppCompatActivity() {
* top of [onCreate]; the controller, session reconciler and merged ViewModel factory are read off it.
*/
private val navRetainedViewModel: NavRetainedViewModel by lazy {
// Seed isOwnTask with isTaskRoot at construction so the controller never starts from a guessed
// default; it is refreshed authoritatively on every onResume (see refreshIsOwnTask). Captured as a
// value here because the initializer lambda's receiver is not this Activity. Only used on the
// genuine first creation — a config change reuses the retained instance and skips the initializer.
val isOwnTaskAtCreation = isTaskRoot
ViewModelProvider(
this,
viewModelFactory { initializer { NavRetainedViewModel((application as HedvigApplication).appGraph) } },
viewModelFactory {
initializer { NavRetainedViewModel((application as HedvigApplication).appGraph, isOwnTaskAtCreation) }
},
)[NavRetainedViewModel::class.java]
}

Expand Down Expand Up @@ -197,6 +204,16 @@ class MainActivity : AppCompatActivity() {
}
}
}
val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
logcat(priority = LogPriority.VERBOSE, tag = DEEP_LINK_STACK_DEBUG_TAG) {
"MainActivity.onCreate launch context: " +
"isColdStart(savedInstanceState==null)=${savedInstanceState == null} | " +
"isTaskRoot=$isTaskRoot | " +
"intent.action=${intent.action} | " +
"intent.data=${intent.data} | " +
"launchedFromHistory=$launchedFromHistory | " +
"intent.flags=0x${Integer.toHexString(intent.flags)}"
}
if (savedInstanceState == null) {
handleDeepLinkIntent(intent)
}
Expand Down Expand Up @@ -267,8 +284,24 @@ class MainActivity : AppCompatActivity() {
* in onCreate — there is no shared, process-wide controller that a backgrounded Activity could
* steal, which is why no resume/top-resume re-attachment is needed.
*/
override fun onResume() {
super.onResume()
refreshIsOwnTask()
}

/**
* Pushes the current [isTaskRoot] into the controller. Unlike the one-time hooks in
* [attachBackstackTaskHooks], this is a *value* that can change over the Activity's life (e.g. an
* Activity below us finishes, or [isTaskRoot] reads more reliably once resumed than at onCreate), so
* it is refreshed on every onResume to keep the snapshot-backed value honest — otherwise the
* lone-deep-link chrome can latch a stale "not own task" reading. See [BackstackController.isOwnTask].
*/
private fun refreshIsOwnTask() {
backstackController.isOwnTask = isTaskRoot
}

private fun attachBackstackTaskHooks() {
backstackController.isOwnTask = { isTaskRoot }
refreshIsOwnTask()
backstackController.escapeToOwnTask = { parentStack ->
NavigationStateBridge.escapeToOwnTask(this@MainActivity, parentStack, serializersModules)
}
Expand All @@ -278,10 +311,18 @@ class MainActivity : AppCompatActivity() {
private fun handleDeepLinkIntent(intent: Intent) {
if (intent.action != Intent.ACTION_VIEW) return
val uri = intent.data?.toString() ?: return
val launchedFromHistory = intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY != 0
logcat(priority = LogPriority.VERBOSE, tag = DEEP_LINK_STACK_DEBUG_TAG) {
"handleDeepLinkIntent forwarding ACTION_VIEW uri=$uri | launchedFromHistory=$launchedFromHistory " +
"(launchedFromHistory=true => STALE re-delivered deep link, NOT a fresh user action)"
}
deepLinkChannel.trySend(uri)
}
}

/** Shared logcat tag for the cold-start/login deep-link-stack investigation. Grep this to follow the flow. */
internal const val DEEP_LINK_STACK_DEBUG_TAG = "DeepLinkStackDebug"

/**
* Applies the theme in two ways:
* 1. Uses UiModeManager to persist the last theme selected so that on new launches the splash screen matches the theme
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import androidx.savedstate.serialization.decodeFromSavedState
import androidx.savedstate.serialization.encodeToSavedState
import com.hedvig.android.app.navigation.BackstackController
import com.hedvig.android.feature.login.navigation.LoginKey
import com.hedvig.android.logger.LogPriority
import com.hedvig.android.logger.logcat
import com.hedvig.android.navigation.common.HedvigNavKey
import com.hedvig.android.navigation.common.StashedSession
import com.hedvig.android.navigation.common.TopLevelTab
import com.hedvig.android.navigation.compose.merge
import kotlin.time.Duration.Companion.minutes
import kotlinx.serialization.Polymorphic
import kotlinx.serialization.PolymorphicSerializer
import kotlinx.serialization.Serializable
Expand All @@ -33,6 +36,14 @@ internal object NavigationStateBridge {
private const val EXTRA_RESTORE_STACK = "com.hedvig.android.app.RESTORE_STACK"
private const val NAV_STATE_REGISTRY_KEY = "com.hedvig.android.app.NAV_STATE"

/**
* How long a [BackstackController.pendingDeepLink] may survive a process death and still be landed at
* the next login. Covers a real "killed mid-login" window (switching to BankID, OTP, etc., is seconds
* to a few minutes) while discarding a link stashed long ago, which would otherwise bleed into an
* unrelated later login and strand the member on a lone deep-link stack. See [restoreAndPersist].
*/
private val MAX_PENDING_DEEP_LINK_AGE = 30.minutes

private val handoffSerializer = ListSerializer(PolymorphicSerializer(HedvigNavKey::class))

/**
Expand Down Expand Up @@ -63,18 +74,49 @@ internal object NavigationStateBridge {
null
}
if (!handoff.isNullOrEmpty()) {
logcat(priority = LogPriority.VERBOSE, tag = DEEP_LINK_STACK_DEBUG_TAG) {
"NavigationStateBridge.restoreAndPersist: escape-to-own-task handoff present, reseeding with $handoff"
}
backstackController.reseed(handoff)
} else {
savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY)
val snapshot = savedStateRegistry.consumeRestoredStateForKey(NAV_STATE_REGISTRY_KEY)
?.let { decodeFromSavedState(NavStateSnapshot.serializer(), it, savedStateConfiguration) }
?.let { snapshot ->
backstackController.restoreFromSavedState(
entries = snapshot.entries,
parkedRuns = snapshot.parkedRuns,
pendingDeepLink = snapshot.pendingDeepLink,
stashedSession = snapshot.stashedSession,
)
if (snapshot != null) {
// Discard a pending deep link that is too old to still belong to an in-flight login, so it
// can't bleed into an unrelated later login as a lone deep-link stack.
val now = System.currentTimeMillis()
val stashedAt = snapshot.pendingDeepLinkStashedAtEpochMs
val ageMs = if (stashedAt != null) now - stashedAt else null
val pendingStillValid = snapshot.pendingDeepLink != null &&
isPendingDeepLinkStashTimeFresh(stashedAt, now)
val restoredPending = snapshot.pendingDeepLink.takeIf { pendingStillValid }
val restoredPendingStashedAt = stashedAt.takeIf { pendingStillValid }
if (snapshot.pendingDeepLink != null && !pendingStillValid) {
logcat(priority = LogPriority.VERBOSE, tag = DEEP_LINK_STACK_DEBUG_TAG) {
"NavigationStateBridge.restoreAndPersist: DROPPING stale restored " +
"pendingDeepLink=${snapshot.pendingDeepLink} (ageMs=$ageMs, max=$MAX_PENDING_DEEP_LINK_AGE) " +
"— it would otherwise have landed as a lone deep-link stack at the next login"
}
}
logcat(priority = LogPriority.VERBOSE, tag = DEEP_LINK_STACK_DEBUG_TAG) {
"NavigationStateBridge.restoreAndPersist: restoring snapshot from SavedStateRegistry " +
"(process-death restore): entries=${snapshot.entries} | " +
"pendingDeepLink=$restoredPending (raw=${snapshot.pendingDeepLink}, ageMs=$ageMs) | " +
"stashedSession.member=${snapshot.stashedSession?.memberId} | " +
"parkedRuns=${snapshot.parkedRuns.keys}"
}
backstackController.restoreFromSavedState(
entries = snapshot.entries,
parkedRuns = snapshot.parkedRuns,
pendingDeepLink = restoredPending,
pendingDeepLinkStashedAtEpochMs = restoredPendingStashedAt,
stashedSession = snapshot.stashedSession,
)
} else {
logcat(priority = LogPriority.VERBOSE, tag = DEEP_LINK_STACK_DEBUG_TAG) {
"NavigationStateBridge.restoreAndPersist: no saved snapshot to restore (isColdStart=$isColdStart)"
}
}
backstackController.seedIfEmpty(listOf(LoginKey))
}

Expand All @@ -87,6 +129,7 @@ internal object NavigationStateBridge {
entries = backstackController.entries.toList(),
parkedRuns = backstackController.parkedRuns.toMap(),
pendingDeepLink = backstackController.pendingDeepLink,
pendingDeepLinkStashedAtEpochMs = backstackController.pendingDeepLinkStashedAtEpochMs,
stashedSession = backstackController.stashedSession,
),
savedStateConfiguration,
Expand Down Expand Up @@ -124,17 +167,34 @@ internal object NavigationStateBridge {
private fun json(serializersModules: Set<SerializersModule>): Json = Json {
serializersModule = serializersModules.merge()
}

/**
* Whether a pending deep link stashed at [stashedAtEpochMs] is recent enough (as of [nowEpochMs]) to
* still be landed after a process-death restore. `null` (no timestamp — e.g. a pre-upgrade snapshot)
* and a negative age (clock moved backwards) both count as not-fresh, so the link is dropped rather
* than risk landing a stale one. Pure and clock-injected so it is unit-testable without Android.
*/
internal fun isPendingDeepLinkStashTimeFresh(stashedAtEpochMs: Long?, nowEpochMs: Long): Boolean {
if (stashedAtEpochMs == null) return false
val ageMs = nowEpochMs - stashedAtEpochMs
return ageMs in 0..MAX_PENDING_DEEP_LINK_AGE.inWholeMilliseconds
}
}

/**
* The full hoisted navigation state, serialized into the Activity's SavedStateRegistry so the
* in-memory [BackstackController] singleton can be re-hydrated after process death. Mirrors the four
* in-memory [BackstackController] singleton can be re-hydrated after process death. Mirrors the
* holders the controller owns.
*
* [pendingDeepLinkStashedAtEpochMs] defaults to `null` so snapshots written before this field existed
* still decode; a missing timestamp means an age can't be computed, so such a restored pending link is
* treated as stale and dropped (see [NavigationStateBridge.restoreAndPersist]).
*/
@Serializable
private data class NavStateSnapshot(
val entries: List<@Polymorphic HedvigNavKey>,
val parkedRuns: Map<TopLevelTab, List<@Polymorphic HedvigNavKey>>,
val pendingDeepLink: (@Polymorphic HedvigNavKey)?,
val stashedSession: StashedSession?,
val pendingDeepLinkStashedAtEpochMs: Long? = null,
)
Loading
Loading