Closer/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt

318 lines
13 KiB
Kotlin

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
)