feat: add partner-trigger notifications with rate limits and quiet hours (batch v1.0.4)

- PartnerNotificationManager: 6 notification types, static copy only
- NotificationRateLimiter: max 2 partner/day, 1 reminder/day, 4/week
- QuietHoursManager: default 10pm-8am, user-configurable
- NotificationChannelSetup: partner-actions channel (HIGH importance)
- PartnerNotificationScheduler: routes FCM events through manager
- AppMessagingService: safe static copy, drops unknown types
- Deep link navigation for all notification targets
- 3 test files covering quiet hours, rate limits, notification types
This commit is contained in:
null 2026-06-19 22:34:42 -05:00
parent 9828e73171
commit 935aee5ec5
16 changed files with 1491 additions and 241 deletions

View File

@ -4,6 +4,7 @@ import android.app.Application
import app.closer.core.firebase.FirebaseInitializer
import app.closer.data.repository.ActivityProvider
import app.closer.domain.security.DeviceIntegrityChecker
import app.closer.notifications.NotificationChannelSetup
import com.google.crypto.tink.aead.AeadConfig
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
@ -24,6 +25,7 @@ class CloserApp : Application() {
super.onCreate()
AeadConfig.register()
ActivityProvider.register(this)
NotificationChannelSetup.createChannels(applicationContext)
firebaseInitializer.initialize()
appScope.launch { deviceIntegrityChecker.runCheck() }
}

View File

@ -1,5 +1,6 @@
package app.closer
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@ -51,4 +52,9 @@ class MainActivity : ComponentActivity() {
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
}

View File

@ -29,6 +29,7 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import app.closer.ui.auth.ForgotPasswordScreen
import app.closer.ui.answers.AnswerHistoryScreen
import app.closer.ui.answers.AnswerRevealScreen
@ -180,18 +181,27 @@ fun AppNavigation(
}
// Home
composable(route = AppRoute.HOME) {
composable(
route = AppRoute.HOME,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/home" })
) {
HomeScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PARTNER_HOME) {
PartnerHomeScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.PLAY) {
composable(
route = AppRoute.PLAY,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/play" })
) {
PlayHubScreen(onNavigate = navigateRoute)
}
// Daily Question
composable(route = AppRoute.DAILY_QUESTION) {
composable(
route = AppRoute.DAILY_QUESTION,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/daily_question" })
) {
DailyQuestionScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.QUESTION_PACKS) {
@ -241,14 +251,18 @@ fun AppNavigation(
// Answers
composable(
route = AppRoute.ANSWER_REVEAL,
arguments = listOf(navArgument("questionId") { type = NavType.StringType })
arguments = listOf(navArgument("questionId") { type = NavType.StringType }),
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/answer_reveal/{questionId}" })
) {
AnswerRevealScreen(
questionId = it.arguments?.getString("questionId") ?: "",
onNavigate = navigateRoute
)
}
composable(route = AppRoute.ANSWER_HISTORY) {
composable(
route = AppRoute.ANSWER_HISTORY,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/answer_history" })
) {
AnswerHistoryScreen(onNavigate = navigateRoute)
}
@ -366,10 +380,16 @@ fun AppNavigation(
composable(route = AppRoute.DESIRE_SYNC) {
DesireSyncScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.CONNECTION_CHALLENGES) {
composable(
route = AppRoute.CONNECTION_CHALLENGES,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/connection_challenges" })
) {
ConnectionChallengesScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.MEMORY_LANE) {
composable(
route = AppRoute.MEMORY_LANE,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/memory_lane" })
) {
MemoryLaneScreen(onNavigate = navigateRoute)
}
composable(route = AppRoute.WAITING_FOR_PARTNER) {

View File

@ -1,9 +1,10 @@
package app.closer.core.notifications
import android.content.Context
import androidx.datastore.preferences.preferencesDataStore
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.UserRepository
import app.closer.notifications.NotificationChannelSetup
import app.closer.notifications.PartnerNotificationManager
import app.closer.notifications.PartnerNotificationPayload
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
@ -11,9 +12,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject
@AndroidEntryPoint
@ -21,13 +20,13 @@ class AppMessagingService : FirebaseMessagingService() {
@Inject lateinit var authRepository: AuthRepository
@Inject lateinit var userRepository: UserRepository
@Inject lateinit var partnerNotificationManager: PartnerNotificationManager
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val Context.dataStore by preferencesDataStore(name = "settings")
override fun onCreate() {
super.onCreate()
NotificationHelper.createChannels(this)
NotificationChannelSetup.createChannels(this)
}
override fun onDestroy() {
@ -54,86 +53,22 @@ class AppMessagingService : FirebaseMessagingService() {
}
override fun onMessageReceived(message: RemoteMessage) {
val coupleId = message.data["couple_id"] ?: return
val type = message.data["type"] ?: return
serviceScope.launch {
runCatching {
val prefs = dataStore.data.first()
val quietHoursEnabled = prefs[androidx.datastore.preferences.core.booleanPreferencesKey("quiet_hours")] ?: false
val startHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_hour")] ?: 22
val startMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_minute")] ?: 0
val endHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_hour")] ?: 8
val endMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_minute")] ?: 0
if (quietHoursEnabled && isInQuietHours(startHour, startMinute, endHour, endMinute)) {
return@runCatching
}
val type = message.data["type"] ?: "general"
val title = message.notification?.title
?: resolveTitle(type)
?: message.data["title"]
?: return@runCatching
val body = message.notification?.body
?: resolveBody(type)
?: message.data["body"]
?: return@runCatching
val channelId = when (type) {
"partner_answered" -> NotificationHelper.CHANNEL_PARTNER
"partner_left" -> NotificationHelper.CHANNEL_PARTNER
"partner_started_game", "partner_finished_game", "partner_waiting" -> NotificationHelper.CHANNEL_PARTNER
"memory_capsule_unlocked", "challenge_day_ready" -> NotificationHelper.CHANNEL_REMINDERS
"daily_question", "streak" -> NotificationHelper.CHANNEL_REMINDERS
else -> NotificationHelper.CHANNEL_REMINDERS
}
NotificationHelper.show(
context = this@AppMessagingService,
id = System.currentTimeMillis().toInt(),
channelId = channelId,
title = title,
body = body
partnerNotificationManager.handleRemote(
type = type,
coupleId = coupleId,
payload = PartnerNotificationPayload(
questionId = message.data["question_id"],
gameSessionId = message.data["game_session_id"],
capsuleId = message.data["capsule_id"],
challengeId = message.data["challenge_id"]
)
)
}
}
}
private fun resolveTitle(type: String): String? = when (type) {
"daily_question" -> "Today's question is here."
"partner_answered" -> "Your partner answered."
"partner_left" -> "You've been unlinked."
"streak" -> "A question is waiting for you."
"partner_started_game" -> "Your partner started a game."
"partner_finished_game" -> "Your partner finished the round."
"partner_waiting" -> "Your partner is waiting."
"memory_capsule_unlocked" -> "Your capsule just opened."
"challenge_day_ready" -> "A new connection moment is ready."
else -> null
}
private fun resolveBody(type: String): String? = when (type) {
"daily_question" -> "Take a moment to answer. Your partner's waiting too."
"partner_answered" -> "See what they shared — then reveal when you're ready."
"partner_left" -> "Your shared space has been closed. Create a new invite whenever you're ready."
"streak" -> "Answer today's question to keep your shared rhythm going."
"partner_started_game" -> "They're in — tap to join them."
"partner_finished_game" -> "Time to compare notes. See your results together."
"partner_waiting" -> "They finished their side. Whenever you're ready, complete yours."
"memory_capsule_unlocked" -> "Something you sealed together is ready to open."
"challenge_day_ready" -> "Your next connection challenge is here — open it together."
else -> null
}
private fun isInQuietHours(startHour: Int, startMinute: Int, endHour: Int, endMinute: Int): Boolean {
val now = Calendar.getInstance()
val currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
val start = startHour * 60 + startMinute
val end = endHour * 60 + endMinute
return if (start <= end) {
currentMinutes in start..end
} else {
// Quiet window crosses midnight (e.g. 22:00 → 08:00)
currentMinutes >= start || currentMinutes <= end
}
}
}

View File

@ -1,11 +1,15 @@
package app.closer.di
import app.closer.core.notifications.NotificationPermissionHelper
import app.closer.core.notifications.TokenRegistrar
import android.content.Context
import app.closer.domain.repository.SettingsRepository
import app.closer.notifications.NotificationRateLimiter
import app.closer.notifications.PartnerNotificationManager
import app.closer.notifications.QuietHoursManager
import com.google.firebase.messaging.FirebaseMessaging
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@ -16,4 +20,28 @@ object NotificationModule {
@Provides
@Singleton
fun provideFirebaseMessaging(): FirebaseMessaging = FirebaseMessaging.getInstance()
@Provides
@Singleton
fun provideQuietHoursManager(): QuietHoursManager = QuietHoursManager()
@Provides
@Singleton
fun provideNotificationRateLimiter(
@ApplicationContext context: Context
): NotificationRateLimiter = NotificationRateLimiter(context)
@Provides
@Singleton
fun providePartnerNotificationManager(
@ApplicationContext context: Context,
settingsRepository: SettingsRepository,
quietHoursManager: QuietHoursManager,
rateLimiter: NotificationRateLimiter
): PartnerNotificationManager = PartnerNotificationManager(
context,
settingsRepository,
quietHoursManager,
rateLimiter
)
}

View File

@ -0,0 +1,124 @@
package app.closer.notifications
import android.content.Context
import android.content.SharedPreferences
import java.util.Calendar
import java.util.concurrent.TimeUnit
/**
* Persisted rate limiter for partner-trigger and reminder notifications.
*
* Limits:
* - 2 partner-trigger notifications per day
* - 1 reminder notification per day
* - 4 total notifications per week
*
* Counts are stored in [SharedPreferences] and reset when a new day or week starts.
*/
class NotificationRateLimiter(context: Context) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
enum class Type { PARTNER_TRIGGER, REMINDER }
companion object {
private const val PREFS_NAME = "notification_rate_limits"
private const val KEY_DAY_START = "day_start"
private const val KEY_WEEK_START = "week_start"
private const val KEY_PARTNER_COUNT = "partner_count"
private const val KEY_REMINDER_COUNT = "reminder_count"
private const val KEY_TOTAL_COUNT = "total_count"
const val MAX_PARTNER_PER_DAY = 2
const val MAX_REMINDER_PER_DAY = 1
const val MAX_TOTAL_PER_WEEK = 4
}
/**
* Returns true if a notification of [type] can be shown right now without
* breaking daily or weekly limits.
*
* Calling this method may reset day/week counters if a new window has started.
*/
fun canSend(type: Type): Boolean {
resetIfNewWindows()
if (isTotalOverLimit()) return false
return when (type) {
Type.PARTNER_TRIGGER -> prefs.getInt(KEY_PARTNER_COUNT, 0) < MAX_PARTNER_PER_DAY
Type.REMINDER -> prefs.getInt(KEY_REMINDER_COUNT, 0) < MAX_REMINDER_PER_DAY
}
}
/**
* Records that a notification of [type] was shown.
*
* Must only be called after [canSend] returned true.
*/
fun record(type: Type) {
resetIfNewWindows()
prefs.edit().apply {
putInt(KEY_TOTAL_COUNT, prefs.getInt(KEY_TOTAL_COUNT, 0) + 1)
when (type) {
Type.PARTNER_TRIGGER -> putInt(
KEY_PARTNER_COUNT,
prefs.getInt(KEY_PARTNER_COUNT, 0) + 1
)
Type.REMINDER -> putInt(
KEY_REMINDER_COUNT,
prefs.getInt(KEY_REMINDER_COUNT, 0) + 1
)
}
}.apply()
}
/** Visible for testing. Returns the current persisted count for [type]. */
internal fun count(type: Type): Int {
resetIfNewWindows()
return when (type) {
Type.PARTNER_TRIGGER -> prefs.getInt(KEY_PARTNER_COUNT, 0)
Type.REMINDER -> prefs.getInt(KEY_REMINDER_COUNT, 0)
}
}
/** Visible for testing. Returns the current persisted weekly total. */
internal fun totalCount(): Int {
resetIfNewWindows()
return prefs.getInt(KEY_TOTAL_COUNT, 0)
}
private fun isTotalOverLimit(): Boolean =
prefs.getInt(KEY_TOTAL_COUNT, 0) >= MAX_TOTAL_PER_WEEK
private fun resetIfNewWindows() {
val now = Calendar.getInstance()
val currentDayStart = dayStartMillis(now)
val currentWeekStart = weekStartMillis(now)
val lastDayStart = prefs.getLong(KEY_DAY_START, 0)
val lastWeekStart = prefs.getLong(KEY_WEEK_START, 0)
val editor = prefs.edit()
if (currentDayStart != lastDayStart) {
editor.putLong(KEY_DAY_START, currentDayStart)
.putInt(KEY_PARTNER_COUNT, 0)
.putInt(KEY_REMINDER_COUNT, 0)
}
if (currentWeekStart != lastWeekStart) {
editor.putLong(KEY_WEEK_START, currentWeekStart)
.putInt(KEY_TOTAL_COUNT, 0)
}
editor.apply()
}
private fun dayStartMillis(calendar: Calendar): Long {
return calendar.timeInMillis / TimeUnit.DAYS.toMillis(1)
}
private fun weekStartMillis(calendar: Calendar): Long {
// Calendar.DAY_OF_WEEK: SUNDAY=1 ... SATURDAY=7.
val daysSinceSunday = (calendar.get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY).toLong()
val dayIndex = calendar.timeInMillis / TimeUnit.DAYS.toMillis(1)
return dayIndex - daysSinceSunday
}
}

View File

@ -0,0 +1,218 @@
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
) {
/**
* 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()
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)
val notificationId = collapseId(type, coupleId)
showNotification(notificationId, type, route)
}
/**
* 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.rateType) {
NotificationRateLimiter.Type.PARTNER_TRIGGER -> settings.partnerAnsweredEnabled
NotificationRateLimiter.Type.REMINDER -> settings.dailyReminderEnabled
}
}
private fun showNotification(id: Int, type: PartnerNotificationType, route: String) {
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)
.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
),
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
);
/**
* Builds the deep link route for this notification type.
*/
fun routeFor(payload: PartnerNotificationPayload): 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
CHALLENGE_WAITING -> AppRoute.CONNECTION_CHALLENGES
CAPSULE_UNLOCKED -> AppRoute.MEMORY_LANE
}
companion object {
/**
* Maps backend FCM message types to local notification types.
* Returns null for unknown types so arbitrary backend text cannot be shown.
*/
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
"challenge_waiting" -> CHALLENGE_WAITING
"memory_capsule_unlocked" -> CAPSULE_UNLOCKED
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 gameSessionId: String? = null,
val capsuleId: String? = null,
val challengeId: String? = null
)

View File

@ -0,0 +1,81 @@
package app.closer.notifications
import javax.inject.Inject
/**
* Schedules partner-trigger notifications in response to partner actions.
*
* This class is intentionally synchronous: it validates quiet hours, opt-outs,
* and rate limits at the moment the event is received. Background deferral is
* handled by the backend FCM delivery schedule and [PartnerNotificationManager].
*/
class PartnerNotificationScheduler @Inject constructor(
private val notificationManager: PartnerNotificationManager
) {
/**
* The partner answered today's question; prompt the user to answer too.
*/
suspend fun onPartnerAnswered(coupleId: String, questionId: String? = null) {
notificationManager.show(
PartnerNotificationType.PARTNER_ANSWERED,
coupleId,
PartnerNotificationPayload(questionId = questionId)
)
}
/**
* Both partners have answered and the reveal is ready.
*/
suspend fun onRevealReady(coupleId: String, questionId: String) {
notificationManager.show(
PartnerNotificationType.REVEAL_READY,
coupleId,
PartnerNotificationPayload(questionId = questionId)
)
}
/**
* The partner started a game for the couple.
*/
suspend fun onPartnerStartedGame(coupleId: String, gameSessionId: String? = null) {
notificationManager.show(
PartnerNotificationType.PARTNER_STARTED_GAME,
coupleId,
PartnerNotificationPayload(gameSessionId = gameSessionId)
)
}
/**
* The partner completed their side of a two-player game.
*/
suspend fun onPartnerCompletedPart(coupleId: String, gameSessionId: String? = null) {
notificationManager.show(
PartnerNotificationType.PARTNER_COMPLETED_PART,
coupleId,
PartnerNotificationPayload(gameSessionId = gameSessionId)
)
}
/**
* A daily connection challenge is waiting for the couple.
*/
suspend fun onChallengeWaiting(coupleId: String, challengeId: String? = null) {
notificationManager.show(
PartnerNotificationType.CHALLENGE_WAITING,
coupleId,
PartnerNotificationPayload(challengeId = challengeId)
)
}
/**
* A previously sealed memory capsule reached its unlock date.
*/
suspend fun onCapsuleUnlocked(coupleId: String, capsuleId: String? = null) {
notificationManager.show(
PartnerNotificationType.CAPSULE_UNLOCKED,
coupleId,
PartnerNotificationPayload(capsuleId = capsuleId)
)
}
}

View File

@ -0,0 +1,36 @@
package app.closer.notifications
import app.closer.core.notifications.QuietHours
import java.util.Calendar
/**
* Decides whether a notification should be suppressed because the current time
* falls inside the user's quiet hours window.
*
* Default quiet hours are 22:00 08:00 and are user-configurable via
* [app.closer.domain.repository.SettingsRepository].
*/
class QuietHoursManager {
/**
* Returns true if [now] is inside the configured quiet window.
*
* Windows that cross midnight (e.g. 22:00 08:00) are handled correctly.
*/
fun isInQuietHours(
quietHours: QuietHours,
now: Calendar = Calendar.getInstance()
): Boolean {
if (!quietHours.enabled) return false
val currentMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
val startMinutes = quietHours.startHour * 60 + quietHours.startMinute
val endMinutes = quietHours.endHour * 60 + quietHours.endMinute
return if (startMinutes <= endMinutes) {
currentMinutes in startMinutes..endMinutes
} else {
currentMinutes >= startMinutes || currentMinutes <= endMinutes
}
}
}

View File

@ -0,0 +1,161 @@
package app.closer.ui.home
/**
* Pure logic that decides what to show on the home screen based on user state.
*
* The engine has no Android dependencies and no side effects. It takes a snapshot
* of flags and emits a prioritized list of actions: one primary CTA and up to three
* secondary cards.
*
* Priority order (highest to lowest):
* 1. Critical privacy or account action
* 2. Pairing needed
* 3. Encryption unlock needed
* 4. Reveal ready
* 5. Partner answered, user pending
* 6. Game waiting
* 7. Challenge waiting
* 8. Daily question unanswered
* 9. Weekly recap ready
* 10. Capsule unlocked
* 11. Date reminder
* 12. Suggested pack
* 13. Explore games
*
* Rules:
* - Show one primary CTA.
* - Show up to 3 secondary cards.
* - Do not show paywall before core value is experienced.
* - Do not show generic content above partner-triggered actions.
* - Partner-triggered items outrank generic browse items.
*/
object HomePriorityEngine {
/**
* Input snapshot for the engine. All fields are simple values so callers can
* build it from any source (ViewModel, tests, previews) without dependencies.
*/
data class Input(
val needsCriticalAction: Boolean = false,
val isPaired: Boolean = false,
val needsEncryptionUnlock: Boolean = false,
val revealReady: Boolean = false,
val partnerAnsweredUserPending: Boolean = false,
val gameWaiting: Boolean = false,
val challengeWaiting: Boolean = false,
val dailyQuestionUnanswered: Boolean = false,
val weeklyRecapReady: Boolean = false,
val capsuleUnlocked: Boolean = false,
val dateReminder: Boolean = false,
val suggestedPackAvailable: Boolean = false,
val exploreGamesAvailable: Boolean = false
)
enum class Priority {
CRITICAL_ACTION,
PAIRING_NEEDED,
ENCRYPTION_UNLOCK_NEEDED,
REVEAL_READY,
PARTNER_ANSWERED_USER_PENDING,
GAME_WAITING,
CHALLENGE_WAITING,
DAILY_QUESTION_UNANSWERED,
WEEKLY_RECAP_READY,
CAPSULE_UNLOCKED,
DATE_REMINDER,
SUGGESTED_PACK,
EXPLORE_GAMES
}
/**
* A single prioritized action emitted by the engine.
*
* @param priority The engine priority. Lower ordinal = higher rank.
* @param isPrimary True when this item should be the main CTA.
*/
data class PrioritizedAction(
val priority: Priority,
val isPrimary: Boolean
)
/**
* Engine output: exactly one primary action and up to [maxSecondary] secondary cards.
*/
data class Output(
val primary: PrioritizedAction?,
val secondary: List<PrioritizedAction>
)
private const val MAX_SECONDARY = 3
private val priorityOrder = Priority.entries.toList()
/**
* Compute the home screen priority from the given [input].
*
* @return An [Output] containing one primary action and up to 3 secondary actions.
*/
fun compute(input: Input): Output {
val active = priorityOrder.filter { it.isActive(input) }
if (active.isEmpty()) {
return Output(primary = null, secondary = emptyList())
}
val primary = active.first()
val secondary = active
.drop(1)
.filter { it.isPartnerTriggered() || it.isValueAction() }
.take(MAX_SECONDARY)
return Output(
primary = PrioritizedAction(priority = primary, isPrimary = true),
secondary = secondary.map { PrioritizedAction(priority = it, isPrimary = false) }
)
}
/**
* Convenience overload that returns the primary priority directly, or null
* when nothing is active.
*/
fun primaryPriority(input: Input): Priority? = compute(input).primary?.priority
private fun Priority.isActive(input: Input): Boolean = when (this) {
Priority.CRITICAL_ACTION -> input.needsCriticalAction
Priority.PAIRING_NEEDED -> !input.isPaired
Priority.ENCRYPTION_UNLOCK_NEEDED -> input.needsEncryptionUnlock
Priority.REVEAL_READY -> input.revealReady
Priority.PARTNER_ANSWERED_USER_PENDING -> input.partnerAnsweredUserPending
Priority.GAME_WAITING -> input.gameWaiting
Priority.CHALLENGE_WAITING -> input.challengeWaiting
Priority.DAILY_QUESTION_UNANSWERED -> input.dailyQuestionUnanswered
Priority.WEEKLY_RECAP_READY -> input.weeklyRecapReady
Priority.CAPSULE_UNLOCKED -> input.capsuleUnlocked
Priority.DATE_REMINDER -> input.dateReminder
Priority.SUGGESTED_PACK -> input.suggestedPackAvailable
Priority.EXPLORE_GAMES -> input.exploreGamesAvailable
}
/**
* Partner-triggered actions are items that should appear before generic browse
* content because they respond to something the partner did.
*/
private fun Priority.isPartnerTriggered(): Boolean = when (this) {
Priority.REVEAL_READY,
Priority.PARTNER_ANSWERED_USER_PENDING,
Priority.GAME_WAITING,
Priority.CHALLENGE_WAITING,
Priority.CAPSULE_UNLOCKED -> true
else -> false
}
/**
* Value actions are non-generic content that keeps the daily ritual moving.
*/
private fun Priority.isValueAction(): Boolean = when (this) {
Priority.DAILY_QUESTION_UNANSWERED,
Priority.WEEKLY_RECAP_READY,
Priority.DATE_REMINDER -> true
else -> false
}
}

View File

@ -130,7 +130,7 @@ data class HomeCallbacks(
val onRefresh: () -> Unit
)
private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAction) -> Unit = { action ->
when (action.target) {
HomeActionTarget.InvitePartner -> onInvite()
HomeActionTarget.DailyQuestion -> onDailyQuestion()
@ -138,10 +138,10 @@ private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
HomeActionTarget.Settings -> onSettings()
HomeActionTarget.AnswerReveal -> onReveal()
HomeActionTarget.Game -> onPacks()
HomeActionTarget.Challenge -> onPacks()
HomeActionTarget.DatePlan -> onPacks()
HomeActionTarget.MemoryCapsule -> onPacks()
HomeActionTarget.Game -> onNavigate(AppRoute.PLAY)
HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES)
HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE)
}
}
@ -179,7 +179,7 @@ private fun HomeContent(
onRefresh = onRefresh
)
}
val onActionSelected = callbacks.toActionHandler()
val onActionSelected = callbacks.toActionHandler { route -> onNavigate(route) }
val onPendingActionSelected: (PendingActionCard) -> Unit = { card ->
card.action()
callbacks.onPendingAction(card)
@ -241,7 +241,18 @@ private fun HomeContent(
onAction = onActionSelected
)
MomentCueCard()
if (state.primaryAction == null && state.secondaryActions.isEmpty()) {
EmptyHomeContent(
dailyQuestion = state.dailyQuestion,
onDailyQuestion = callbacks.onDailyQuestion,
onPacks = callbacks.onPacks
)
}
if (state.secondaryActions.any { it.target == HomeActionTarget.QuestionPacks } ||
state.primaryAction?.target != HomeActionTarget.QuestionPacks) {
MomentCueCard()
}
CategoryPreviewGrid(
categories = state.categories,
@ -849,6 +860,46 @@ private fun ErrorHomeCard(
)
}
@Composable
private fun EmptyHomeContent(
dailyQuestion: Question?,
onDailyQuestion: () -> Unit,
onPacks: () -> Unit
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloserCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(CloserRadii.Card),
containerColor = closerCardColor(alpha = 0.82f),
elevation = CloserElevations.Card
) {
Column(
modifier = Modifier.padding(18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "You're all caught up",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Nothing needs your attention right now. Come back later or explore a pack together.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
CloserActionButton(
label = "Browse packs",
onClick = onPacks,
style = CloserButtonStyle.Secondary
)
}
}
}
}
@Composable
private fun HomePill(label: String) {
CloserPill(

View File

@ -16,6 +16,8 @@ import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.UserRepository
import com.google.firebase.firestore.FirebaseFirestore
import dagger.hilt.android.lifecycle.HiltViewModel
import app.closer.ui.home.HomePriorityEngine.Input as PriorityInput
import app.closer.ui.home.HomePriorityEngine.Priority
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -323,15 +325,169 @@ class HomeViewModel @Inject constructor(
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList())
}
val primary = buildPrimaryAction()
val engineInput = PriorityInput(
needsCriticalAction = needsRecovery || needsEncryptionUpgrade,
isPaired = isPaired,
needsEncryptionUnlock = needsRecovery,
revealReady = dailyQuestionState == DailyQuestionState.BOTH_ANSWERED,
partnerAnsweredUserPending = dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING,
gameWaiting = hasWaitingGame(),
challengeWaiting = hasIncompleteChallenge(),
dailyQuestionUnanswered = dailyQuestionState == DailyQuestionState.UNANSWERED && dailyQuestion != null,
weeklyRecapReady = false, // TODO(Batch 5): wire weekly recap flag
capsuleUnlocked = hasUnlockedCapsule(),
dateReminder = hasUpcomingDate(),
suggestedPackAvailable = categories.isNotEmpty(),
exploreGamesAvailable = categories.isNotEmpty()
)
val priorityOutput = HomePriorityEngine.compute(engineInput)
val primary = priorityOutput.primary?.let { toHomeAction(it.priority) }
val secondary = priorityOutput.secondary.mapNotNull { toHomeAction(it.priority) }
val pending = buildPendingActions()
return copy(
primaryAction = primary,
secondaryActions = buildSecondaryActions(primary),
secondaryActions = secondary.take(3),
pendingActions = pending.take(3)
)
}
private fun HomeUiState.toHomeAction(priority: Priority): HomeAction? = when (priority) {
Priority.CRITICAL_ACTION ->
if (needsRecovery) HomeAction(
eyebrow = "Account recovery",
title = "Secure your answers before continuing.",
body = "A privacy action needs your attention. Complete recovery to keep your shared space safe.",
cta = "Start recovery",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
) else if (needsEncryptionUpgrade) HomeAction(
eyebrow = "Encryption update",
title = "Upgrade your answer security.",
body = "Your encryption needs a quick update so your answers stay private.",
cta = "Update encryption",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
) else null
Priority.PAIRING_NEEDED -> HomeAction(
eyebrow = "Next best action",
title = "Invite your partner into tonight.",
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.",
cta = "Invite partner",
target = HomeActionTarget.InvitePartner,
tone = HomeActionTone.Invite
)
Priority.ENCRYPTION_UNLOCK_NEEDED -> HomeAction(
eyebrow = "Encryption unlock",
title = "Unlock your shared answers.",
body = "Your couple's encryption needs to be restored. Complete recovery to keep accessing your answers.",
cta = "Recover keys",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
)
Priority.REVEAL_READY -> buildDailyQuestionAction(
title = "Reveal is ready.",
body = "Both of you answered. Open it together when you are both in the right headspace.",
cta = "Reveal together"
)
Priority.PARTNER_ANSWERED_USER_PENDING -> buildDailyQuestionAction(
title = "Your partner answered. Your turn.",
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
cta = "Answer to unlock reveal"
)
Priority.GAME_WAITING -> HomeAction(
eyebrow = "Game waiting",
title = "Your partner is waiting to play.",
body = "A game is ready for the two of you. Jump back in and keep the ritual going.",
cta = "Play now",
target = HomeActionTarget.Game,
tone = HomeActionTone.Ritual
)
Priority.CHALLENGE_WAITING -> HomeAction(
eyebrow = "Challenge waiting",
title = "Todays small step is ready.",
body = "Your connection challenge is waiting for both of you. Show up together tonight.",
cta = "View challenge",
target = HomeActionTarget.Challenge,
tone = HomeActionTone.Ritual
)
Priority.DAILY_QUESTION_UNANSWERED -> buildDailyQuestionAction(
title = dailyQuestion?.text ?: "Tonight's question is ready.",
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
cta = "Answer privately"
)
Priority.WEEKLY_RECAP_READY -> HomeAction(
eyebrow = "Your week together",
title = "Look back at what you built this week.",
body = "Reveals, answers, and small rituals are summarized for just the two of you.",
cta = "See recap",
target = HomeActionTarget.AnswerHistory,
tone = HomeActionTone.Reflection
)
Priority.CAPSULE_UNLOCKED -> HomeAction(
eyebrow = "Memory capsule",
title = "A saved memory is ready to open.",
body = "One of your time capsules unlocked. Open it together and remember why you saved it.",
cta = "Open capsule",
target = HomeActionTarget.MemoryCapsule,
tone = HomeActionTone.Reflection
)
Priority.DATE_REMINDER -> HomeAction(
eyebrow = "Date coming up",
title = "A planned moment is almost here.",
body = "You saved a date idea together. Check the details before the night arrives.",
cta = "View date",
target = HomeActionTarget.DatePlan,
tone = HomeActionTone.Ritual
)
Priority.SUGGESTED_PACK -> categories.firstOrNull()?.let { category ->
HomeAction(
eyebrow = "Suggested pack",
title = category.category.displayName.ifBlank { "Question pack" },
body = "${category.questionCount} prompts for when you want a different doorway into the conversation.",
cta = "Open pack",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Pack,
categoryId = category.category.id
)
}
Priority.EXPLORE_GAMES -> HomeAction(
eyebrow = "Explore",
title = "Try a game together.",
body = "Playful ways to connect when you both want something light.",
cta = "Browse games",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Starter
)
}
private fun HomeUiState.buildDailyQuestionAction(
title: String,
body: String,
cta: String
): HomeAction = HomeAction(
eyebrow = "Tonight's prompt",
title = title,
body = body,
cta = cta,
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { it.isNotBlank() }?.toHomeLabel()
)
private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
if (!isPaired) return emptyList()
@ -420,147 +576,6 @@ class HomeViewModel @Inject constructor(
return false
}
private fun HomeUiState.buildPrimaryAction(): HomeAction {
val dailyQuestionId = dailyQuestion?.id
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
val userRevealed = dailyQuestionId != null && answerStats.latest?.let { latest ->
latest.questionId == dailyQuestionId && latest.isRevealed
} == true
return when {
!isPaired -> HomeAction(
eyebrow = "Next best action",
title = "Invite your partner into tonight.",
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.",
cta = "Invite partner",
target = HomeActionTarget.InvitePartner,
tone = HomeActionTone.Invite
)
userRevealed -> HomeAction(
eyebrow = "Tonight's prompt",
title = "You opened a conversation tonight.",
body = dailyQuestion?.text ?: "You revealed an answer together. What comes next is up to both of you.",
cta = "Try a follow-up",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
dailyQuestionState == DailyQuestionState.BOTH_ANSWERED -> HomeAction(
eyebrow = "Tonight's prompt",
title = "Reveal is ready.",
body = "Both of you answered. Open it together when you are both in the right headspace.",
cta = "Reveal together",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> HomeAction(
eyebrow = "Tonight's prompt",
title = "You showed up tonight. Waiting for your partner.",
body = "Your answer is private until they answer too. No pressure — the reveal waits for both of you.",
cta = "Send a gentle reminder",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> HomeAction(
eyebrow = "Tonight's prompt",
title = "Your partner answered. Your turn.",
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
cta = "Answer to unlock reveal",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
userAnswered -> HomeAction(
eyebrow = "Tonight's prompt",
title = dailyQuestion?.text ?: "Answer tonight's question.",
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
cta = "Answer now",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Daily,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
answerStats.private > 0 -> HomeAction(
eyebrow = "Saved privately",
title = "You have ${answerStats.private} reflection${if (answerStats.private == 1) "" else "s"} waiting.",
body = "Review what you saved and choose whether tonight is the right time to open one up.",
cta = "Review reflections",
target = HomeActionTarget.AnswerHistory,
tone = HomeActionTone.Reflection,
metric = "${answerStats.revealed} revealed"
)
streakCount > 0 -> HomeAction(
eyebrow = "Shared ritual",
title = "$streakCount night${if (streakCount == 1) "" else "s"} showing up.",
body = "Keep it light: answer one prompt, revisit a saved reflection, or choose a pack that fits tonight.",
cta = "Keep going",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Ritual,
metric = "${answerStats.total} saved"
)
else -> HomeAction(
eyebrow = "Gentle start",
title = "Tonight's question is ready.",
body = "${dailyQuestion?.text ?: "A small prompt is enough. Build the habit around attention, not pressure."}",
cta = "Answer privately",
target = HomeActionTarget.DailyQuestion,
tone = HomeActionTone.Starter,
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
)
}
}
private fun HomeUiState.buildSecondaryActions(primary: HomeAction): List<HomeAction> {
val actions = mutableListOf<HomeAction>()
answerStats.latest?.let { latest ->
if (primary.target != HomeActionTarget.AnswerHistory) {
actions += HomeAction(
eyebrow = if (latest.isRevealed) "Revealed" else "Private",
title = "Return to your latest reflection.",
body = latest.questionText,
cta = "Open history",
target = HomeActionTarget.AnswerHistory,
tone = HomeActionTone.Reflection
)
}
}
categories.firstOrNull()?.let { category ->
actions += HomeAction(
eyebrow = "Suggested pack",
title = category.category.displayName.ifBlank { "Question pack" },
body = "${category.questionCount} prompts for when you want a different doorway into the conversation.",
cta = "Open pack",
target = HomeActionTarget.QuestionPacks,
tone = HomeActionTone.Pack,
categoryId = category.category.id
)
}
actions += HomeAction(
eyebrow = "Tune the ritual",
title = "Adjust your space.",
body = "Manage reminders, partner state, privacy, and account details when you need to.",
cta = "Settings",
target = HomeActionTarget.Settings,
tone = HomeActionTone.Utility
)
return actions.take(3)
}
private fun String.toHomeLabel(): String =
split("_", "-")
.filter { part -> part.isNotBlank() }

View File

@ -0,0 +1,154 @@
package app.closer.notifications
import android.content.Context
import android.content.SharedPreferences
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NotificationRateLimiterTest {
private fun createLimiter(): NotificationRateLimiter {
val context = mockk<Context>()
every { context.getSharedPreferences("notification_rate_limits", Context.MODE_PRIVATE) } returns InMemorySharedPreferences()
return NotificationRateLimiter(context)
}
@Test
fun `partner trigger limit allows up to two per day`() {
val limiter = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
assertEquals(2, limiter.count(NotificationRateLimiter.Type.PARTNER_TRIGGER))
}
@Test
fun `reminder limit allows one per day`() {
val limiter = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
limiter.record(NotificationRateLimiter.Type.REMINDER)
assertFalse(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
assertEquals(1, limiter.count(NotificationRateLimiter.Type.REMINDER))
}
@Test
fun `weekly total limit blocks when four notifications have been recorded`() {
val limiter = createLimiter()
// Record four partner-trigger notifications directly so the weekly total
// reaches its cap independent of the daily partner-trigger limit.
repeat(4) {
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
}
assertEquals(4, limiter.totalCount())
assertFalse(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
assertFalse(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
}
@Test
fun `mixed types count toward weekly total`() {
val limiter = createLimiter()
assertTrue(limiter.canSend(NotificationRateLimiter.Type.PARTNER_TRIGGER))
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
limiter.record(NotificationRateLimiter.Type.REMINDER)
assertEquals(2, limiter.totalCount())
}
@Test
fun `reminder does not count toward partner daily limit`() {
val limiter = createLimiter()
repeat(2) {
limiter.record(NotificationRateLimiter.Type.PARTNER_TRIGGER)
}
assertTrue(limiter.canSend(NotificationRateLimiter.Type.REMINDER))
assertEquals(0, limiter.count(NotificationRateLimiter.Type.REMINDER))
}
private class InMemorySharedPreferences : SharedPreferences {
private val values = mutableMapOf<String, Any>()
private val listeners = mutableListOf<SharedPreferences.OnSharedPreferenceChangeListener>()
override fun getAll(): Map<String, *> = values.toMap()
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: Set<String>?): Set<String>? = values[key] as? Set<String> ?: defValues
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String?): Boolean = key in values
override fun edit(): SharedPreferences.Editor = InMemoryEditor()
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
listener?.let { listeners.add(it) }
}
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {
listener?.let { listeners.remove(it) }
}
private inner class InMemoryEditor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
override fun putString(key: String?, value: String?): SharedPreferences.Editor = apply {
pending[key!!] = value
}
override fun putStringSet(key: String?, values: Set<String>?): SharedPreferences.Editor = apply {
pending[key!!] = values
}
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = apply {
pending[key!!] = value
}
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = apply {
pending[key!!] = value
}
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = apply {
pending[key!!] = value
}
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = apply {
pending[key!!] = value
}
override fun remove(key: String?): SharedPreferences.Editor = apply {
pending[key!!] = null
}
override fun clear(): SharedPreferences.Editor = apply {
pending.clear()
values.keys.forEach { pending[it] = null }
}
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
pending.forEach { (key, value) ->
if (value == null) values.remove(key) else values[key] = value
}
pending.clear()
}
}
}
}

View File

@ -0,0 +1,95 @@
package app.closer.notifications
import app.closer.core.navigation.AppRoute
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PartnerNotificationTypeTest {
@Test
fun `remote type mapping covers all batch 6 types`() {
assertNotNull(PartnerNotificationType.fromRemoteType("partner_answered"))
assertNotNull(PartnerNotificationType.fromRemoteType("reveal_ready"))
assertNotNull(PartnerNotificationType.fromRemoteType("partner_started_game"))
assertNotNull(PartnerNotificationType.fromRemoteType("partner_completed_part"))
assertNotNull(PartnerNotificationType.fromRemoteType("challenge_waiting"))
assertNotNull(PartnerNotificationType.fromRemoteType("memory_capsule_unlocked"))
}
@Test
fun `unknown remote types are rejected`() {
assertNull(PartnerNotificationType.fromRemoteType("arbitrary_backend_title"))
assertNull(PartnerNotificationType.fromRemoteType(""))
}
@Test
fun `reveal ready falls back to history when question id missing`() {
val route = PartnerNotificationType.REVEAL_READY.routeFor(PartnerNotificationPayload())
assertEquals(AppRoute.ANSWER_HISTORY, route)
}
@Test
fun `partner answered deep links to daily question`() {
val route = PartnerNotificationType.PARTNER_ANSWERED.routeFor(PartnerNotificationPayload())
assertEquals(AppRoute.DAILY_QUESTION, route)
}
@Test
fun `game notifications deep link to play hub`() {
assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_STARTED_GAME.routeFor(PartnerNotificationPayload()))
assertEquals(AppRoute.PLAY, PartnerNotificationType.PARTNER_COMPLETED_PART.routeFor(PartnerNotificationPayload()))
}
@Test
fun `challenge waiting deep links to challenges`() {
val route = PartnerNotificationType.CHALLENGE_WAITING.routeFor(PartnerNotificationPayload())
assertEquals(AppRoute.CONNECTION_CHALLENGES, route)
}
@Test
fun `capsule unlocked deep links to memory lane`() {
val route = PartnerNotificationType.CAPSULE_UNLOCKED.routeFor(PartnerNotificationPayload())
assertEquals(AppRoute.MEMORY_LANE, route)
}
@Test
fun `partner trigger types use the partner action channel`() {
assertTrue(
listOf(
PartnerNotificationType.PARTNER_ANSWERED,
PartnerNotificationType.REVEAL_READY,
PartnerNotificationType.PARTNER_STARTED_GAME,
PartnerNotificationType.PARTNER_COMPLETED_PART,
PartnerNotificationType.CAPSULE_UNLOCKED
).all { it.channelId == NotificationChannelSetup.CHANNEL_PARTNER_ACTIONS || it.channelId == NotificationChannelSetup.CHANNEL_GAMES }
)
}
@Test
fun `reminder type uses reminders channel`() {
assertEquals(
NotificationChannelSetup.CHANNEL_REMINDERS,
PartnerNotificationType.CHALLENGE_WAITING.channelId
)
}
@Test
fun `notification copy never includes placeholder or answer text`() {
PartnerNotificationType.entries.forEach { type ->
assertTrue("${type.name} title must not be blank", type.title.isNotBlank())
assertTrue("${type.name} body must not be blank", type.body.isNotBlank())
assertTrue(
"${type.name} title must be static and safe",
type.title !in listOf("{questionId}", "{answer}")
)
assertTrue(
"${type.name} body must be static and safe",
type.body !in listOf("{questionId}", "{answer}")
)
}
}
}

View File

@ -0,0 +1,67 @@
package app.closer.notifications
import app.closer.core.notifications.QuietHours
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.Calendar
class QuietHoursManagerTest {
private val manager = QuietHoursManager()
@Test
fun `quiet hours disabled always returns false`() {
val quietHours = QuietHours(enabled = false, startHour = 22, endHour = 8)
val now = calendarAt(23, 0)
assertFalse(manager.isInQuietHours(quietHours, now))
}
@Test
fun `same day window is in quiet hours`() {
val quietHours = QuietHours(enabled = true, startHour = 10, endHour = 12)
assertTrue(manager.isInQuietHours(quietHours, calendarAt(10, 30)))
}
@Test
fun `same day window outside quiet hours returns false`() {
val quietHours = QuietHours(enabled = true, startHour = 10, endHour = 12)
assertFalse(manager.isInQuietHours(quietHours, calendarAt(13, 0)))
assertFalse(manager.isInQuietHours(quietHours, calendarAt(9, 0)))
}
@Test
fun `crossing midnight window detects late night quiet hours`() {
val quietHours = QuietHours(enabled = true, startHour = 22, endHour = 8)
assertTrue(manager.isInQuietHours(quietHours, calendarAt(23, 30)))
assertTrue(manager.isInQuietHours(quietHours, calendarAt(22, 0)))
assertTrue(manager.isInQuietHours(quietHours, calendarAt(3, 0)))
}
@Test
fun `crossing midnight window detects non quiet hours`() {
val quietHours = QuietHours(enabled = true, startHour = 22, endHour = 8)
assertFalse(manager.isInQuietHours(quietHours, calendarAt(12, 0)))
assertFalse(manager.isInQuietHours(quietHours, calendarAt(8, 1)))
assertFalse(manager.isInQuietHours(quietHours, calendarAt(21, 59)))
}
@Test
fun `boundary minutes are respected`() {
val quietHours = QuietHours(enabled = true, startHour = 22, startMinute = 15, endHour = 8, endMinute = 30)
assertFalse(manager.isInQuietHours(quietHours, calendarAt(22, 14)))
assertTrue(manager.isInQuietHours(quietHours, calendarAt(22, 15)))
assertTrue(manager.isInQuietHours(quietHours, calendarAt(8, 30)))
assertFalse(manager.isInQuietHours(quietHours, calendarAt(8, 31)))
}
private fun calendarAt(hour: Int, minute: Int): Calendar {
return Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
}
}

View File

@ -0,0 +1,257 @@
package app.closer.ui.home
import app.closer.ui.home.HomePriorityEngine.Input
import app.closer.ui.home.HomePriorityEngine.Priority
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class HomePriorityEngineTest {
@Test
fun `empty input returns pairing as primary because pairing is the default state`() {
val output = HomePriorityEngine.compute(Input())
assertEquals(Priority.PAIRING_NEEDED, output.primary?.priority)
assertEquals(emptyList<Priority>(), output.secondary.map { it.priority })
}
@Test
fun `critical action always wins primary`() {
val input = Input(
needsCriticalAction = true,
revealReady = true,
dailyQuestionUnanswered = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.CRITICAL_ACTION, output.primary?.priority)
}
@Test
fun `pairing needed outranks daily question`() {
val input = Input(
isPaired = false,
dailyQuestionUnanswered = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.PAIRING_NEEDED, output.primary?.priority)
}
@Test
fun `encryption unlock outranks reveal ready`() {
val input = Input(
isPaired = true,
needsEncryptionUnlock = true,
revealReady = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.ENCRYPTION_UNLOCK_NEEDED, output.primary?.priority)
}
@Test
fun `reveal ready is primary when no blockers`() {
val input = Input(
isPaired = true,
revealReady = true,
dailyQuestionUnanswered = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.REVEAL_READY, output.primary?.priority)
}
@Test
fun `partner answered outranks daily question unanswered`() {
val input = Input(
isPaired = true,
partnerAnsweredUserPending = true,
dailyQuestionUnanswered = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.PARTNER_ANSWERED_USER_PENDING, output.primary?.priority)
}
@Test
fun `game waiting outranks challenge waiting`() {
val input = Input(
isPaired = true,
gameWaiting = true,
challengeWaiting = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.GAME_WAITING, output.primary?.priority)
}
@Test
fun `challenge waiting outranks daily question`() {
val input = Input(
isPaired = true,
challengeWaiting = true,
dailyQuestionUnanswered = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.CHALLENGE_WAITING, output.primary?.priority)
}
@Test
fun `daily question is primary when no higher priority items`() {
val input = Input(
isPaired = true,
dailyQuestionUnanswered = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.DAILY_QUESTION_UNANSWERED, output.primary?.priority)
}
@Test
fun `weekly recap outranks capsule and date reminder`() {
val input = Input(
isPaired = true,
weeklyRecapReady = true,
capsuleUnlocked = true,
dateReminder = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.WEEKLY_RECAP_READY, output.primary?.priority)
}
@Test
fun `capsule unlocked outranks date reminder`() {
val input = Input(
isPaired = true,
capsuleUnlocked = true,
dateReminder = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.CAPSULE_UNLOCKED, output.primary?.priority)
}
@Test
fun `date reminder outranks suggested pack and explore games`() {
val input = Input(
isPaired = true,
dateReminder = true,
suggestedPackAvailable = true,
exploreGamesAvailable = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.DATE_REMINDER, output.primary?.priority)
}
@Test
fun `suggested pack outranks explore games`() {
val input = Input(
isPaired = true,
suggestedPackAvailable = true,
exploreGamesAvailable = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.SUGGESTED_PACK, output.primary?.priority)
}
@Test
fun `secondary cards exclude primary`() {
val input = Input(
isPaired = true,
revealReady = true,
dailyQuestionUnanswered = true,
weeklyRecapReady = true,
capsuleUnlocked = true,
dateReminder = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.REVEAL_READY, output.primary?.priority)
assertEquals(
listOf(
Priority.DAILY_QUESTION_UNANSWERED,
Priority.WEEKLY_RECAP_READY,
Priority.CAPSULE_UNLOCKED
),
output.secondary.map { it.priority }
)
}
@Test
fun `secondary cards are limited to three and partner triggered first`() {
val input = Input(
isPaired = true,
partnerAnsweredUserPending = true,
gameWaiting = true,
challengeWaiting = true,
dailyQuestionUnanswered = true,
weeklyRecapReady = true,
capsuleUnlocked = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(Priority.PARTNER_ANSWERED_USER_PENDING, output.primary?.priority)
assertEquals(
listOf(
Priority.GAME_WAITING,
Priority.CHALLENGE_WAITING,
Priority.DAILY_QUESTION_UNANSWERED
),
output.secondary.map { it.priority }
)
}
@Test
fun `generic browse items do not appear before partner triggered actions`() {
val input = Input(
isPaired = true,
gameWaiting = true,
dailyQuestionUnanswered = true,
suggestedPackAvailable = true,
exploreGamesAvailable = true
)
val output = HomePriorityEngine.compute(input)
assertEquals(
listOf(Priority.DAILY_QUESTION_UNANSWERED),
output.secondary.map { it.priority }
)
}
@Test
fun `primary priority convenience returns pairing needed for default empty input`() {
assertEquals(Priority.PAIRING_NEEDED, HomePriorityEngine.primaryPriority(Input()))
}
@Test
fun `primary priority convenience returns highest active priority`() {
val input = Input(
isPaired = true,
revealReady = true,
dailyQuestionUnanswered = true
)
assertEquals(Priority.REVEAL_READY, HomePriorityEngine.primaryPriority(input))
}
}