diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index 8df9ff4e..b7525ad6 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -73,6 +73,7 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var tokenRegistrar: TokenRegistrar @Inject lateinit var retentionAnalytics: app.closer.analytics.RetentionAnalytics @Inject lateinit var pendingJoinCodeStore: app.closer.data.local.PendingJoinCodeStore + @Inject lateinit var coupleKeyRotationAdopter: app.closer.domain.usecase.CoupleKeyRotationAdopter private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } @@ -109,6 +110,7 @@ class MainActivity : AppCompatActivity() { } maybeRequestNotificationPermission() registerFcmToken() + adoptCoupleKeyRotation() pendingDeepLink.value = deepLinkRouteFromIntent(intent) if (BuildConfig.DEBUG) attemptDebugAutoLogin() // Drive the REAL Configuration uiMode from the in-app theme (Settings → Appearance) so that @@ -281,6 +283,24 @@ class MainActivity : AppCompatActivity() { } } + /** + * Adopt a couple-key rotation the partner performed, before any screen needs the key. Home does + * this too, but people don't always land on Home — a chat notification opens the conversation and + * a cold start restores the last screen, both of which would render new-key content as locked + * until Home happened to load. Best-effort, mirrors [registerFcmToken]. + */ + private fun adoptCoupleKeyRotation() { + lifecycleScope.launch { + authRepository.authState + .filterIsInstance() + .map { it.userId } + .distinctUntilChanged() + .collect { uid -> + runCatching { coupleKeyRotationAdopter.adoptIfNeeded(uid) } + } + } + } + private fun maybeRequestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) diff --git a/app/src/main/java/app/closer/domain/usecase/CoupleKeyRotationAdopter.kt b/app/src/main/java/app/closer/domain/usecase/CoupleKeyRotationAdopter.kt new file mode 100644 index 00000000..19fa3616 --- /dev/null +++ b/app/src/main/java/app/closer/domain/usecase/CoupleKeyRotationAdopter.kt @@ -0,0 +1,47 @@ +package app.closer.domain.usecase + +import app.closer.core.crash.CrashReporter +import app.closer.crypto.CoupleEncryptionManager +import app.closer.domain.repository.CoupleRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Picks up a couple-key rotation performed on the partner's device, as early as possible after + * sign-in. + * + * Home's own load does this too, but Home is not guaranteed to be where people land: a chat + * notification opens the conversation directly, and the app restores its last screen on cold start. + * Caught live — a partner reopened into a thread after a rotation and every message written under the + * new key rendered "🔒 Couldn't unlock on this device" until they happened to tap Home. The key is + * an app-level fact, so adopting it is app-level work, not Home's. + * + * Best-effort and never throws: it runs on the startup path beside FCM registration, and a device + * that can't adopt right now is not broken — all pre-rotation content still decrypts, and Home's + * hook plus the next launch both try again. + */ +@Singleton +class CoupleKeyRotationAdopter @Inject constructor( + private val coupleRepository: CoupleRepository, + private val encryptionManager: CoupleEncryptionManager, + private val crashReporter: CrashReporter +) { + /** Startup entry point: resolves the couple itself. Returns the outcome for callers that care. */ + suspend fun adoptIfNeeded(uid: String): CoupleEncryptionManager.AdoptionResult { + val couple = runCatching { coupleRepository.getCoupleForUser(uid) } + .onFailure { crashReporter.recordException(it) } + .getOrNull() + ?: return CoupleEncryptionManager.AdoptionResult.UpToDate + return adoptForCouple(couple) + } + + /** For callers that already hold the couple (Home) — no redundant read. */ + suspend fun adoptForCouple(couple: app.closer.domain.model.Couple): CoupleEncryptionManager.AdoptionResult = + when (val result = encryptionManager.adoptRotationIfNeeded(couple)) { + is CoupleEncryptionManager.AdoptionResult.Failed -> { + crashReporter.recordException(result.cause) + result + } + else -> result + } +} diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 2424facc..463a3d2d 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -56,6 +56,7 @@ class HomeViewModel @Inject constructor( private val coupleRepository: CoupleRepository, private val userRepository: UserRepository, private val encryptionManager: CoupleEncryptionManager, + private val coupleKeyRotationAdopter: app.closer.domain.usecase.CoupleKeyRotationAdopter, private val db: FirebaseFirestore, private val functions: FirebaseFunctions, private val questionSessionRepository: QuestionSessionRepository, @@ -166,7 +167,7 @@ class HomeViewModel @Inject constructor( // difference between "it just works" and a screen full of 🔒. Old content is safe // either way — the local keyset is only ever replaced on success. if (couple != null && encryptionStatus == EncryptionStatus.UNLOCKED) { - when (val adoption = encryptionManager.adoptRotationIfNeeded(couple)) { + when (val adoption = coupleKeyRotationAdopter.adoptForCouple(couple)) { is CoupleEncryptionManager.AdoptionResult.Adopted -> Log.i(TAG, "Adopted rotated couple key (generation ${couple.keyGeneration})") is CoupleEncryptionManager.AdoptionResult.PhraseMissing -> {