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:
parent
06e4d609f2
commit
a8fbbaa286
|
|
@ -32,6 +32,8 @@ import app.closer.BuildConfig
|
||||||
import app.closer.core.navigation.AppNavigation
|
import app.closer.core.navigation.AppNavigation
|
||||||
import app.closer.core.notifications.TokenRegistrar
|
import app.closer.core.notifications.TokenRegistrar
|
||||||
import app.closer.domain.model.AuthState
|
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.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
@ -55,10 +57,15 @@ class MainActivity : AppCompatActivity() {
|
||||||
private val notificationPermissionLauncher =
|
private val notificationPermissionLauncher =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
maybeRequestNotificationPermission()
|
maybeRequestNotificationPermission()
|
||||||
registerFcmToken()
|
registerFcmToken()
|
||||||
|
pendingDeepLink.value = deepLinkRouteFromIntent(intent)
|
||||||
if (BuildConfig.DEBUG) attemptDebugAutoLogin()
|
if (BuildConfig.DEBUG) attemptDebugAutoLogin()
|
||||||
setContent {
|
setContent {
|
||||||
val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
|
val settings by settingsRepository.settings.collectAsState(initial = AppSettings())
|
||||||
|
|
@ -91,7 +98,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
onUnlocked = { sessionVerified = true }
|
onUnlocked = { sessionVerified = true }
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AppNavigation()
|
AppNavigation(
|
||||||
|
pendingDeepLink = pendingDeepLink.value,
|
||||||
|
onDeepLinkConsumed = { pendingDeepLink.value = null }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -101,6 +111,28 @@ class MainActivity : AppCompatActivity() {
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import androidx.navigation.navDeepLink
|
import androidx.navigation.navDeepLink
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import app.closer.ui.auth.ForgotPasswordScreen
|
import app.closer.ui.auth.ForgotPasswordScreen
|
||||||
import app.closer.ui.answers.AnswerHistoryScreen
|
import app.closer.ui.answers.AnswerHistoryScreen
|
||||||
import app.closer.ui.answers.AnswerRevealScreen
|
import app.closer.ui.answers.AnswerRevealScreen
|
||||||
|
|
@ -87,7 +88,9 @@ import app.closer.ui.games.WaitingForPartnerScreen
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavigation(
|
fun AppNavigation(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
startDestination: String = AppRoute.ONBOARDING
|
startDestination: String = AppRoute.ONBOARDING,
|
||||||
|
pendingDeepLink: String? = null,
|
||||||
|
onDeepLinkConsumed: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
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(
|
androidx.compose.foundation.layout.Box(
|
||||||
modifier = androidx.compose.ui.Modifier.fillMaxSize()
|
modifier = androidx.compose.ui.Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,15 @@ export const onMessageWritten = functions.firestore
|
||||||
return
|
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 = {
|
const payload: admin.messaging.MessagingPayload = {
|
||||||
notification: {
|
notification: {
|
||||||
title: 'Your partner sent a message',
|
title: authorName ? `${authorName} sent a message` : 'Your partner sent a message',
|
||||||
body: 'Tap to read and reply.',
|
body: 'Tap to read and reply.',
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -91,6 +97,7 @@ export const onMessageWritten = functions.firestore
|
||||||
couple_id: coupleId,
|
couple_id: coupleId,
|
||||||
thread_id: threadId,
|
thread_id: threadId,
|
||||||
...(questionId ? { question_id: questionId } : {}),
|
...(questionId ? { question_id: questionId } : {}),
|
||||||
|
...(authorPhotoUrl ? { sender_avatar_url: authorPhotoUrl } : {}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue