From 935aee5ec5058b901d2f9c270beac21d04c39303 Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 22:34:42 -0500 Subject: [PATCH] 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 --- app/src/main/java/app/closer/CloserApp.kt | 2 + app/src/main/java/app/closer/MainActivity.kt | 6 + .../closer/core/navigation/AppNavigation.kt | 34 +- .../core/notifications/AppMessagingService.kt | 99 +----- .../java/app/closer/di/NotificationModule.kt | 32 +- .../notifications/NotificationRateLimiter.kt | 124 ++++++++ .../PartnerNotificationManager.kt | 218 +++++++++++++ .../PartnerNotificationScheduler.kt | 81 +++++ .../closer/notifications/QuietHoursManager.kt | 36 +++ .../app/closer/ui/home/HomePriorityEngine.kt | 161 ++++++++++ .../java/app/closer/ui/home/HomeScreen.kt | 65 +++- .../java/app/closer/ui/home/HomeViewModel.kt | 301 +++++++++--------- .../NotificationRateLimiterTest.kt | 154 +++++++++ .../PartnerNotificationTypeTest.kt | 95 ++++++ .../notifications/QuietHoursManagerTest.kt | 67 ++++ .../closer/ui/home/HomePriorityEngineTest.kt | 257 +++++++++++++++ 16 files changed, 1491 insertions(+), 241 deletions(-) create mode 100644 app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt create mode 100644 app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt create mode 100644 app/src/main/java/app/closer/notifications/PartnerNotificationScheduler.kt create mode 100644 app/src/main/java/app/closer/notifications/QuietHoursManager.kt create mode 100644 app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt create mode 100644 app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt create mode 100644 app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt create mode 100644 app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt create mode 100644 app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt diff --git a/app/src/main/java/app/closer/CloserApp.kt b/app/src/main/java/app/closer/CloserApp.kt index fac23c6d..51c061dd 100644 --- a/app/src/main/java/app/closer/CloserApp.kt +++ b/app/src/main/java/app/closer/CloserApp.kt @@ -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() } } diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index b02a17f7..d2e0af7a 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -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) + } } diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 049838dd..c44593de 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -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) { diff --git a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt index 53d9bf8b..2631917c 100644 --- a/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt +++ b/app/src/main/java/app/closer/core/notifications/AppMessagingService.kt @@ -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 - } - } } diff --git a/app/src/main/java/app/closer/di/NotificationModule.kt b/app/src/main/java/app/closer/di/NotificationModule.kt index 302d0df5..46e930b3 100644 --- a/app/src/main/java/app/closer/di/NotificationModule.kt +++ b/app/src/main/java/app/closer/di/NotificationModule.kt @@ -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 + ) } diff --git a/app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt b/app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt new file mode 100644 index 00000000..f0179325 --- /dev/null +++ b/app/src/main/java/app/closer/notifications/NotificationRateLimiter.kt @@ -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 + } +} diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt new file mode 100644 index 00000000..ed6eb078 --- /dev/null +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationManager.kt @@ -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 +) diff --git a/app/src/main/java/app/closer/notifications/PartnerNotificationScheduler.kt b/app/src/main/java/app/closer/notifications/PartnerNotificationScheduler.kt new file mode 100644 index 00000000..626e11ca --- /dev/null +++ b/app/src/main/java/app/closer/notifications/PartnerNotificationScheduler.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/app/closer/notifications/QuietHoursManager.kt b/app/src/main/java/app/closer/notifications/QuietHoursManager.kt new file mode 100644 index 00000000..9d882ba4 --- /dev/null +++ b/app/src/main/java/app/closer/notifications/QuietHoursManager.kt @@ -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 + } + } +} diff --git a/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt b/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt new file mode 100644 index 00000000..a6664bcb --- /dev/null +++ b/app/src/main/java/app/closer/ui/home/HomePriorityEngine.kt @@ -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 + ) + + 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 + } +} diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index 257f87a9..433bb691 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -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( diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 231ae12a..ac8221d5 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 = "Today’s 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 { 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 { - val actions = mutableListOf() - - 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() } diff --git a/app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt b/app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt new file mode 100644 index 00000000..37302f95 --- /dev/null +++ b/app/src/test/java/app/closer/notifications/NotificationRateLimiterTest.kt @@ -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() + 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() + private val listeners = mutableListOf() + + override fun getAll(): Map = values.toMap() + override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue + override fun getStringSet(key: String?, defValues: Set?): Set? = values[key] as? Set ?: 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() + + override fun putString(key: String?, value: String?): SharedPreferences.Editor = apply { + pending[key!!] = value + } + + override fun putStringSet(key: String?, values: Set?): 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() + } + } + } +} diff --git a/app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt b/app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt new file mode 100644 index 00000000..2b273cfb --- /dev/null +++ b/app/src/test/java/app/closer/notifications/PartnerNotificationTypeTest.kt @@ -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}") + ) + } + } +} diff --git a/app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt b/app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt new file mode 100644 index 00000000..94054315 --- /dev/null +++ b/app/src/test/java/app/closer/notifications/QuietHoursManagerTest.kt @@ -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) + } + } +} diff --git a/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt b/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt new file mode 100644 index 00000000..a1e6f8e5 --- /dev/null +++ b/app/src/test/java/app/closer/ui/home/HomePriorityEngineTest.kt @@ -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(), 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)) + } +}