package app.closer.notifications import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import app.closer.MainActivity import app.closer.R import app.closer.core.navigation.AppRoute import app.closer.domain.repository.AppSettings import app.closer.domain.repository.SettingsRepository import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.first import javax.inject.Inject /** * Manages local and FCM-driven partner-triggered notifications. * * Responsibilities: * - Enforce notification opt-outs from [SettingsRepository]. * - Respect quiet hours via [QuietHoursManager]. * - Apply rate limits via [NotificationRateLimiter]. * - Collapse duplicate notifications by deriving the notification ID from * the notification type and couple ID hash. * - Deep link each notification to the correct screen. * * Security: notification titles and bodies are static. Answer text, prompt * text, and other sensitive content are never included. */ class PartnerNotificationManager @Inject constructor( @ApplicationContext private val context: Context, private val settingsRepository: SettingsRepository, private val quietHoursManager: QuietHoursManager, private val rateLimiter: NotificationRateLimiter, private val activeThreadMonitor: ActiveThreadMonitor ) { /** * Shows a partner-trigger notification if the user has not opted out, * quiet hours are not active, and rate limits allow it. * * @param type The notification type (decides copy, channel, and deep link). * @param coupleId The couple ID used for duplicate collapse. * @param payload Optional IDs needed to build the exact deep link route. */ suspend fun show( type: PartnerNotificationType, coupleId: String, payload: PartnerNotificationPayload = PartnerNotificationPayload() ) { if (coupleId.isBlank()) return val settings = settingsRepository.settings.first() // Don't ping for a chat message in the conversation the user is already reading live. if (type == PartnerNotificationType.CHAT_MESSAGE && payload.conversationId != null && payload.conversationId == activeThreadMonitor.activeConversationId ) return if (!isEnabled(type, settings)) return if (quietHoursManager.isInQuietHours(settings.quietHours)) return if (!rateLimiter.canSend(type.rateType)) return rateLimiter.record(type.rateType) val route = type.routeFor(payload, coupleId) val notificationId = collapseId(type, coupleId) val avatar = payload.avatarUrl?.takeIf { it.isNotBlank() }?.let { loadAvatar(it) } showNotification(notificationId, type, route, avatar) } /** Best-effort partner-avatar load for a richer notification; null on any failure. */ private suspend fun loadAvatar(url: String): android.graphics.Bitmap? = runCatching { val loader = coil.ImageLoader(context) val request = coil.request.ImageRequest.Builder(context) .data(url) .allowHardware(false) .build() (loader.execute(request).drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap }.getOrNull() /** * Maps a remote FCM message type to a [PartnerNotificationType] and shows it. * * Unknown types are ignored so the backend cannot force arbitrary notification text. */ suspend fun handleRemote( type: String, coupleId: String, payload: PartnerNotificationPayload = PartnerNotificationPayload() ) { val notificationType = PartnerNotificationType.fromRemoteType(type) ?: return show(notificationType, coupleId, payload) } private fun isEnabled(type: PartnerNotificationType, settings: AppSettings): Boolean { return when (type) { PartnerNotificationType.CHAT_MESSAGE -> settings.chatMessageEnabled PartnerNotificationType.OUTCOME_REMINDER -> settings.outcomeReminderEnabled else -> when (type.rateType) { NotificationRateLimiter.Type.PARTNER_TRIGGER -> settings.partnerAnsweredEnabled NotificationRateLimiter.Type.REMINDER -> settings.dailyReminderEnabled } } } private fun showNotification( id: Int, type: PartnerNotificationType, route: String, largeIcon: android.graphics.Bitmap? = null ) { if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return val deepLinkUri = Uri.parse("${DEEP_LINK_SCHEME}://$DEEP_LINK_HOST/$route") val intent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW data = deepLinkUri flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP } val pendingIntent = PendingIntent.getActivity( context, id, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat.Builder(context, type.channelId) .setSmallIcon(R.drawable.ic_launcher_foreground) .setContentTitle(type.title) .setContentText(type.body) .setAutoCancel(true) .setContentIntent(pendingIntent) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .apply { if (largeIcon != null) setLargeIcon(largeIcon) } .build() NotificationManagerCompat.from(context).notify(id, notification) } private fun collapseId(type: PartnerNotificationType, coupleId: String): Int { // Same type + same couple always collapses to the same ID, // preventing duplicate notifications in the shade. val hash = type.name.hashCode() * 31 + coupleId.hashCode() return (hash and 0x7FFFFFFF) % ID_MODULO } companion object { private const val DEEP_LINK_SCHEME = "closer" private const val DEEP_LINK_HOST = "closer.app" private const val ID_MODULO = 100_000 } } /** * The supported partner-trigger notification types. * * Titles and bodies are fixed strings; no user-generated content is ever shown. */ enum class PartnerNotificationType( val title: String, val body: String, val channelId: String, val rateType: NotificationRateLimiter.Type ) { PARTNER_ANSWERED( title = "Your partner answered.", body = "Your turn to unlock the reveal.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), REVEAL_READY( title = "Your reveal is ready.", body = "Open it when you're both ready.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), PARTNER_STARTED_GAME( title = "Your partner started a game for the two of you.", body = "Tap to join them.", channelId = NotificationChannelSetup.CHANNEL_GAMES, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), PARTNER_COMPLETED_PART( title = "Your partner finished their part.", body = "Open yours when you're ready.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), GAME_RESULTS_READY( title = "Your game results are ready!", body = "You both finished — tap to see how you compare.", channelId = NotificationChannelSetup.CHANNEL_GAMES, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), CHALLENGE_WAITING( title = "Tonight's small challenge is waiting.", body = "A little shared moment is ready.", channelId = NotificationChannelSetup.CHANNEL_REMINDERS, rateType = NotificationRateLimiter.Type.REMINDER ), CAPSULE_UNLOCKED( title = "A memory capsule just unlocked.", body = "Something you sealed together is ready to open.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), GENTLE_REMINDER( title = "Your partner is thinking about you.", body = "They left tonight's question open. Answer when you're ready.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), DAILY_QUESTION_REMINDER( title = "Tonight's question is waiting.", body = "Answer together before it expires.", channelId = NotificationChannelSetup.CHANNEL_REMINDERS, rateType = NotificationRateLimiter.Type.REMINDER ), CHAT_MESSAGE( title = "Your partner sent a message.", body = "Tap to read and reply.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), OUTCOME_REMINDER( title = "How did that go?", body = "Take a moment to reflect on your last date.", channelId = NotificationChannelSetup.CHANNEL_REMINDERS, rateType = NotificationRateLimiter.Type.REMINDER ), PARTNER_JOINED( title = "Your partner joined!", body = "You're connected. Time to answer tonight's question together.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), DATE_MATCH( title = "It's a match!", body = "You both want to go on this date. Time to make it happen.", channelId = NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS, rateType = NotificationRateLimiter.Type.PARTNER_TRIGGER ), REENGAGEMENT( title = "It's been a while.", body = "Tonight's question is a good reason to reconnect.", channelId = NotificationChannelSetup.CHANNEL_REMINDERS, rateType = NotificationRateLimiter.Type.REMINDER ); /** * Builds the deep link route for this notification type. */ fun routeFor(payload: PartnerNotificationPayload, coupleId: String = ""): String = when (this) { PARTNER_ANSWERED -> AppRoute.DAILY_QUESTION REVEAL_READY -> payload.questionId?.let { AppRoute.answerReveal(it) } ?: AppRoute.ANSWER_HISTORY PARTNER_STARTED_GAME -> AppRoute.PLAY PARTNER_COMPLETED_PART -> AppRoute.PLAY GAME_RESULTS_READY -> AppRoute.PLAY CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE GENTLE_REMINDER -> AppRoute.DAILY_QUESTION DAILY_QUESTION_REMINDER -> AppRoute.DAILY_QUESTION // Open the actual conversation so the partner can reply in place. CHAT_MESSAGE -> if (payload.conversationId != null && coupleId.isNotBlank()) { AppRoute.conversation(coupleId, payload.conversationId) } else { AppRoute.MESSAGES } OUTCOME_REMINDER -> AppRoute.SETTINGS PARTNER_JOINED -> if (coupleId.isNotBlank()) AppRoute.pairingSuccess(coupleId) else AppRoute.HOME DATE_MATCH -> AppRoute.DATE_MATCHES REENGAGEMENT -> AppRoute.DAILY_QUESTION } companion object { fun fromRemoteType(type: String): PartnerNotificationType? = when (type) { "partner_answered" -> PARTNER_ANSWERED "reveal_ready" -> REVEAL_READY "partner_started_game" -> PARTNER_STARTED_GAME "partner_completed_part" -> PARTNER_COMPLETED_PART // Server (onGameSessionUpdate) emits this type once BOTH partners finish — the reveal // is ready, so this maps to the results-ready copy (not "open yours when ready"). "partner_finished_game" -> GAME_RESULTS_READY "game_results_ready" -> GAME_RESULTS_READY "challenge_waiting" -> CHALLENGE_WAITING "memory_capsule_unlocked" -> CAPSULE_UNLOCKED "gentle_reminder" -> GENTLE_REMINDER "daily_question_reminder" -> DAILY_QUESTION_REMINDER "chat_message" -> CHAT_MESSAGE "outcome_reminder" -> OUTCOME_REMINDER "partner_joined" -> PARTNER_JOINED "date_match" -> DATE_MATCH "reengagement" -> REENGAGEMENT else -> null } } } /** * Optional IDs used to build exact deep link routes. * * No answer text, prompt text, or decrypted content is included. */ data class PartnerNotificationPayload( val questionId: String? = null, val conversationId: String? = null, val gameSessionId: String? = null, val capsuleId: String? = null, val challengeId: String? = null, /** Sender's avatar URL, used as the notification large icon when present. */ val avatarUrl: String? = null )