feat(notifications): deep link routing from FCM data extras, onMessageWritten includes author name + photo

- MainActivity: deepLinkRouteFromIntent resolves FCM data extras to navigation routes; pendingDeepLink state for onNewIntent
- AppNavigation: LaunchedEffect waits for HOME route before navigating deep link (fixes race with onboarding)
- onMessageWritten: includes author displayName + photoUrl in notification payload
This commit is contained in:
null 2026-06-24 15:20:24 -05:00
parent 06e4d609f2
commit a8fbbaa286
3 changed files with 61 additions and 3 deletions

View File

@ -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<String?>(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)
}
/**

View File

@ -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()
) {

View File

@ -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 } : {}),
},
}