318 lines
13 KiB
Kotlin
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
|
|
)
|