diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index 00c059b7..6d96f5a7 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -32,6 +32,8 @@ import app.closer.BuildConfig import app.closer.core.navigation.AppNavigation import app.closer.core.notifications.TokenRegistrar import app.closer.domain.model.AuthState +import app.closer.notifications.PartnerNotificationPayload +import app.closer.notifications.PartnerNotificationType import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map @@ -55,10 +57,15 @@ class MainActivity : AppCompatActivity() { private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } + // Route to navigate to after a notification tap (set from the launch intent). Backed by state + // so a tap while the app is already running (onNewIntent) also re-triggers navigation. + private val pendingDeepLink = mutableStateOf(null) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) maybeRequestNotificationPermission() registerFcmToken() + pendingDeepLink.value = deepLinkRouteFromIntent(intent) if (BuildConfig.DEBUG) attemptDebugAutoLogin() setContent { val settings by settingsRepository.settings.collectAsState(initial = AppSettings()) @@ -91,7 +98,10 @@ class MainActivity : AppCompatActivity() { onUnlocked = { sessionVerified = true } ) } else { - AppNavigation() + AppNavigation( + pendingDeepLink = pendingDeepLink.value, + onDeepLinkConsumed = { pendingDeepLink.value = null } + ) } } } @@ -101,6 +111,28 @@ class MainActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + deepLinkRouteFromIntent(intent)?.let { pendingDeepLink.value = it } + } + + /** + * Resolves a navigation route from a notification tap. When the app is backgrounded/closed the + * OS shows the FCM `notification` block and, on tap, launches us with the message `data` as + * plain intent extras (no deep-link Uri) — so we rebuild the route here. A real deep-link Uri + * (from our own foreground-posted PendingIntent) is left for the NavHost to handle. + */ + private fun deepLinkRouteFromIntent(intent: Intent?): String? { + intent ?: return null + if (intent.data != null) return null + val type = intent.getStringExtra("type") ?: return null + val coupleId = intent.getStringExtra("couple_id") ?: "" + val payload = PartnerNotificationPayload( + questionId = intent.getStringExtra("question_id"), + gameSessionId = intent.getStringExtra("game_session_id"), + capsuleId = intent.getStringExtra("capsule_id"), + challengeId = intent.getStringExtra("challenge_id"), + avatarUrl = intent.getStringExtra("sender_avatar_url") + ) + return PartnerNotificationType.fromRemoteType(type)?.routeFor(payload, coupleId) } /** diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 50e1731e..30ef0522 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -31,6 +31,7 @@ import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.navArgument import androidx.navigation.navDeepLink import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect import app.closer.ui.auth.ForgotPasswordScreen import app.closer.ui.answers.AnswerHistoryScreen import app.closer.ui.answers.AnswerRevealScreen @@ -87,7 +88,9 @@ import app.closer.ui.games.WaitingForPartnerScreen @Composable fun AppNavigation( modifier: Modifier = Modifier, - startDestination: String = AppRoute.ONBOARDING + startDestination: String = AppRoute.ONBOARDING, + pendingDeepLink: String? = null, + onDeepLinkConsumed: () -> Unit = {} ) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -130,6 +133,22 @@ fun AppNavigation( } } + // A partner/chat notification was tapped while the app was backgrounded or closed. The OS + // delivers the FCM payload as intent extras (not a deep-link Uri), so we resolve a route in + // MainActivity and navigate here — but only once the user is past onboarding (authenticated + + // on the main graph), otherwise the destination can't load and the tap appears to do nothing. + LaunchedEffect(pendingDeepLink, currentRoute) { + val link = pendingDeepLink ?: return@LaunchedEffect + // Wait until the user has actually settled on Home (authenticated + onboarding finished). + // Navigating during the onboarding→home transition races its popUpTo, which discards the + // destination — the symptom of "the app opens but the message never loads". + if (currentRoute == AppRoute.HOME) { + kotlinx.coroutines.delay(350) + navigateRoute(link) + onDeepLinkConsumed() + } + } + androidx.compose.foundation.layout.Box( modifier = androidx.compose.ui.Modifier.fillMaxSize() ) { diff --git a/functions/src/questions/onMessageWritten.ts b/functions/src/questions/onMessageWritten.ts index 1d53b319..a8d46fab 100644 --- a/functions/src/questions/onMessageWritten.ts +++ b/functions/src/questions/onMessageWritten.ts @@ -81,9 +81,15 @@ export const onMessageWritten = functions.firestore return } + // The recipient sees the message from the author (their partner), so surface the author's + // photo/name — the in-app chat bubble uses sender_avatar_url to show the partner's face. + const authorDoc = await db.collection('users').doc(authorId).get() + const authorPhotoUrl = (authorDoc.data()?.photoUrl as string | undefined) ?? '' + const authorName = (authorDoc.data()?.displayName as string | undefined) ?? '' + const payload: admin.messaging.MessagingPayload = { notification: { - title: 'Your partner sent a message', + title: authorName ? `${authorName} sent a message` : 'Your partner sent a message', body: 'Tap to read and reply.', }, data: { @@ -91,6 +97,7 @@ export const onMessageWritten = functions.firestore couple_id: coupleId, thread_id: threadId, ...(questionId ? { question_id: questionId } : {}), + ...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}), }, }