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
321 changes: 194 additions & 127 deletions CLAUDE.md

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion app/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ dependencies {

implementation(platform(libs.firebase.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appstate)
implementation(libs.androidx.compose.animationCore)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3.windowSizeClass)
Expand Down Expand Up @@ -189,13 +188,16 @@ dependencies {
implementation(projects.featureChat)
implementation(projects.featureChipId)
implementation(projects.featureChooseTier)
implementation(projects.featureChooseTierNavigation)
implementation(projects.featureClaimChat)
implementation(projects.featureClaimDetails)
implementation(projects.featureClaimHistory)
implementation(projects.featureConnectPaymentTrustly)
implementation(projects.featureConnectPaymentTrustlyNavigation)
implementation(projects.featureCrossSellSheet)
implementation(projects.featureDeleteAccount)
implementation(projects.featureEditCoinsured)
implementation(projects.featureEditCoinsuredNavigation)
implementation(projects.featureFlags)
implementation(projects.featureForever)
implementation(projects.featureHelpCenter)
Expand All @@ -205,13 +207,16 @@ dependencies {
implementation(projects.featureInsurances)
implementation(projects.featureLogin)
implementation(projects.featureMovingflow)
implementation(projects.featureMovingflowNavigation)

implementation(projects.featureRemoveAddons)
implementation(projects.featurePayoutAccount)
implementation(projects.featurePayments)
implementation(projects.featureProfile)
implementation(projects.featureTerminateInsurance)
implementation(projects.featureTerminateInsuranceNavigation)
implementation(projects.featureTravelCertificate)
implementation(projects.featureTravelCertificateNavigation)
implementation(projects.foreverUi)
implementation(projects.initializable)
implementation(projects.languageCore)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
package com.hedvig.android.app

import android.app.Activity
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.enableEdgeToEdge
import com.google.android.play.core.review.ReviewException
import com.google.android.play.core.review.ReviewManagerFactory
import com.hedvig.android.logger.LogPriority
import com.hedvig.android.logger.logcat

/**
* The Activity-bound capabilities the Compose app shell needs from its host. Implemented by
Expand All @@ -16,3 +23,49 @@ internal interface AndroidAppHost {

fun tryShowAppStoreReviewDialog()
}

internal class AndroidAppHostImpl(private val activity: ComponentActivity): AndroidAppHost {
override fun finishApp() = activity.finish()

override fun applyEdgeToEdgeStyle(systemBarStyle: SystemBarStyle) {
activity.enableEdgeToEdge(
statusBarStyle = systemBarStyle,
navigationBarStyle = systemBarStyle,
)
}

override fun shouldShowPermissionRationale(permission: String): Boolean =
activity.shouldShowRequestPermissionRationale(permission)

override fun tryShowAppStoreReviewDialog() = activity.tryShowPlayStoreReviewDialog()
}

private fun Activity.tryShowPlayStoreReviewDialog() {
val tag = "PlayStoreReview"
val manager = ReviewManagerFactory.create(this)
logcat(LogPriority.INFO) { "$tag: requestReviewFlow" }
manager.requestReviewFlow().apply {
addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: requestReviewFlow failed:${it.message}" } }
addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: requestReviewFlow cancelled" } }
addOnCompleteListener { task ->
if (task.isSuccessful) {
logcat(LogPriority.INFO) { "$tag: requestReviewFlow completed" }
val reviewInfo = task.result
logcat(LogPriority.INFO) { "$tag: launchReviewFlow with ReviewInfo:$reviewInfo" }
manager.launchReviewFlow(this@tryShowPlayStoreReviewDialog, reviewInfo).apply {
addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: launchReviewFlow failed:${it.message}" } }
addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow canceled" } }
addOnCompleteListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow completed" } }
}
} else {
val exception = task.exception
val errorMessage = if (exception != null && exception is ReviewException) {
"ReviewException:${exception.message}. ReviewException::errorCode:${exception.errorCode}"
} else {
"Unknown error with message: ${exception?.message}"
}
logcat(LogPriority.INFO, exception) { "$tag: requestReviewFlow failed. Error:$errorMessage" }
}
}
}
}
132 changes: 13 additions & 119 deletions app/app/src/main/kotlin/com/hedvig/android/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,11 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.foundation.ComposeFoundationFlags
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.getSystemService
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.serialization.SavedStateConfiguration
import androidx.savedstate.serialization.decodeFromSavedState
import androidx.savedstate.serialization.encodeToSavedState
import coil3.ImageLoader
import com.google.android.play.core.review.ReviewException
import com.google.android.play.core.review.ReviewManagerFactory
Expand All @@ -46,17 +40,12 @@ import com.hedvig.android.core.demomode.Provider
import com.hedvig.android.core.rive.RiveInitializer
import com.hedvig.android.data.paying.member.GetOnlyHasNonPayingContractsUseCase
import com.hedvig.android.data.settings.datastore.SettingsDataStore
import com.hedvig.android.feature.login.navigation.LoginKey
import com.hedvig.android.featureflags.FeatureManager
import com.hedvig.android.language.LanguageLaunchCheckUseCase
import com.hedvig.android.language.LanguageService
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.HedvigDeepLinkMatcher
import com.hedvig.android.navigation.compose.merge
import com.hedvig.android.notification.badge.data.payment.MissedPaymentNotificationService
import com.hedvig.android.theme.Theme
import dev.zacsweers.metro.Inject
Expand All @@ -65,8 +54,6 @@ import java.util.Locale
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.serialization.Polymorphic
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule

class MainActivity : AppCompatActivity() {
Expand Down Expand Up @@ -178,84 +165,35 @@ class MainActivity : AppCompatActivity() {
addOnNewIntentListener { newIntent -> handleDeepLinkIntent(newIntent) }

val externalNavigator = ExternalNavigatorImpl(this, hedvigBuildConstants.appPackageId)
val androidAppHost = object : AndroidAppHost {
override fun finishApp() = finish()

override fun applyEdgeToEdgeStyle(systemBarStyle: SystemBarStyle) {
enableEdgeToEdge(
statusBarStyle = systemBarStyle,
navigationBarStyle = systemBarStyle,
)
}

override fun shouldShowPermissionRationale(permission: String): Boolean =
shouldShowRequestPermissionRationale(permission)

override fun tryShowAppStoreReviewDialog() = tryShowPlayStoreReviewDialog()
}
val androidAppHost = AndroidAppHostImpl(this)
RiveInitializer.init(this)
val savedStateConfiguration = SavedStateConfiguration {
serializersModule = serializersModules.merge()
}
// Attach the Activity-bound task hooks to the app-scoped controller. Done here (not at
// construction) and re-attached on every recreation: the singleton outlives any single Activity,
// so capturing an Activity-bound lambda in the constructor would be the stale-reference leak we
// set out to avoid.
backstackController.isOwnTask = { isTaskRoot }
backstackController.escapeToOwnTask = { parentStack ->
RestoredBackstackTransfer.escapeToOwnTask(this@MainActivity, parentStack, serializersModules)
}
val restoredBackstack = if (savedInstanceState != null) {
null
} else {
RestoredBackstackTransfer.readFrom(intent, serializersModules)
}
// Seed / restore the hoisted (app-scoped) navigation state. Precedence:
// 1. An explicit deep-link / escape re-root replaces everything.
// 2. Otherwise, on a cold start after process death, re-hydrate the full state from this
// Activity's SavedStateRegistry. On a config change the live singleton is already populated,
// so restoreFromSavedState is a no-op there and the live state wins.
// 3. Finally guarantee at least a Login root.
if (!restoredBackstack.isNullOrEmpty()) {
backstackController.reseed(restoredBackstack)
} else {
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,
)
}
backstackController.seedIfEmpty(listOf(LoginKey))
NavigationStateBridge.escapeToOwnTask(this@MainActivity, parentStack, serializersModules)
}
// Persist the live navigation state across process death. The provider is invoked at save time
// and serializes whatever the singleton holds then, so a Presenter-driven navigation is captured.
savedStateRegistry.registerSavedStateProvider(
NAV_STATE_REGISTRY_KEY,
SavedStateRegistry.SavedStateProvider {
encodeToSavedState(
NavStateSnapshot.serializer(),
NavStateSnapshot(
entries = backstackController.entries.toList(),
parkedRuns = backstackController.parkedRuns.toMap(),
pendingDeepLink = backstackController.pendingDeepLink,
stashedSession = backstackController.stashedSession,
),
savedStateConfiguration,
)
},
backstackController.finishApp = androidAppHost::finishApp
NavigationStateBridge.restoreAndPersist(
backstackController = backstackController,
savedStateRegistry = savedStateRegistry,
intent = intent,
isColdStart = savedInstanceState == null,
serializersModules = serializersModules,
)
lifecycleScope.launch {
sessionReconciler.reconcile()
sessionReconciler.observeForcedLogout(lifecycle)
}
setContent {
CompositionLocalProvider(
LocalMetroViewModelFactory provides (application as HedvigApplication).appGraph.metroViewModelFactory,
) {
val windowSizeClass = calculateWindowSizeClass(this@MainActivity)
HedvigApp(
backstackController = backstackController,
sessionReconciler = sessionReconciler,
deepLinkChannel = deepLinkChannel,
windowSizeClass = windowSizeClass,
settingsDataStore = settingsDataStore,
Expand Down Expand Up @@ -323,50 +261,6 @@ private fun applyTheme(theme: Theme?, uiModeManager: UiModeManager?) {
}
}

private fun Activity.tryShowPlayStoreReviewDialog() {
val tag = "PlayStoreReview"
val manager = ReviewManagerFactory.create(this)
logcat(LogPriority.INFO) { "$tag: requestReviewFlow" }
manager.requestReviewFlow().apply {
addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: requestReviewFlow failed:${it.message}" } }
addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: requestReviewFlow cancelled" } }
addOnCompleteListener { task ->
if (task.isSuccessful) {
logcat(LogPriority.INFO) { "$tag: requestReviewFlow completed" }
val reviewInfo = task.result
logcat(LogPriority.INFO) { "$tag: launchReviewFlow with ReviewInfo:$reviewInfo" }
manager.launchReviewFlow(this@tryShowPlayStoreReviewDialog, reviewInfo).apply {
addOnFailureListener { logcat(LogPriority.INFO, it) { "$tag: launchReviewFlow failed:${it.message}" } }
addOnCanceledListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow canceled" } }
addOnCompleteListener { logcat(LogPriority.INFO) { "$tag: launchReviewFlow completed" } }
}
} else {
val exception = task.exception
val errorMessage = if (exception != null && exception is ReviewException) {
"ReviewException:${exception.message}. ReviewException::errorCode:${exception.errorCode}"
} else {
"Unknown error with message: ${exception?.message}"
}
logcat(LogPriority.INFO, exception) { "$tag: requestReviewFlow failed. Error:$errorMessage" }
}
}
}
}

private const val NAV_STATE_REGISTRY_KEY = "com.hedvig.android.app.NAV_STATE"

/**
* 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
* holders the controller owns.
*/
@Serializable
private data class NavStateSnapshot(
val entries: List<@Polymorphic HedvigNavKey>,
val parkedRuns: Map<TopLevelTab, List<@Polymorphic HedvigNavKey>>,
val pendingDeepLink: (@Polymorphic HedvigNavKey)?,
val stashedSession: StashedSession?,
)

private fun getSystemLocale(config: android.content.res.Configuration): Locale {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
Expand Down
Loading
Loading