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

View File

@ -1,5 +1,6 @@
package app.closer package app.closer
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent 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.compose.rememberNavController
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.navArgument import androidx.navigation.navArgument
import androidx.navigation.navDeepLink
import app.closer.ui.auth.ForgotPasswordScreen import app.closer.ui.auth.ForgotPasswordScreen
import app.closer.ui.answers.AnswerHistoryScreen import app.closer.ui.answers.AnswerHistoryScreen
import app.closer.ui.answers.AnswerRevealScreen import app.closer.ui.answers.AnswerRevealScreen
@ -180,18 +181,27 @@ fun AppNavigation(
} }
// Home // Home
composable(route = AppRoute.HOME) { composable(
route = AppRoute.HOME,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/home" })
) {
HomeScreen(onNavigate = navigateRoute) HomeScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.PARTNER_HOME) { composable(route = AppRoute.PARTNER_HOME) {
PartnerHomeScreen(onNavigate = navigateRoute) PartnerHomeScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.PLAY) { composable(
route = AppRoute.PLAY,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/play" })
) {
PlayHubScreen(onNavigate = navigateRoute) PlayHubScreen(onNavigate = navigateRoute)
} }
// Daily Question // Daily Question
composable(route = AppRoute.DAILY_QUESTION) { composable(
route = AppRoute.DAILY_QUESTION,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/daily_question" })
) {
DailyQuestionScreen(onNavigate = navigateRoute) DailyQuestionScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.QUESTION_PACKS) { composable(route = AppRoute.QUESTION_PACKS) {
@ -241,14 +251,18 @@ fun AppNavigation(
// Answers // Answers
composable( composable(
route = AppRoute.ANSWER_REVEAL, 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( AnswerRevealScreen(
questionId = it.arguments?.getString("questionId") ?: "", questionId = it.arguments?.getString("questionId") ?: "",
onNavigate = navigateRoute onNavigate = navigateRoute
) )
} }
composable(route = AppRoute.ANSWER_HISTORY) { composable(
route = AppRoute.ANSWER_HISTORY,
deepLinks = listOf(navDeepLink { uriPattern = "closer://closer.app/answer_history" })
) {
AnswerHistoryScreen(onNavigate = navigateRoute) AnswerHistoryScreen(onNavigate = navigateRoute)
} }
@ -366,10 +380,16 @@ fun AppNavigation(
composable(route = AppRoute.DESIRE_SYNC) { composable(route = AppRoute.DESIRE_SYNC) {
DesireSyncScreen(onNavigate = navigateRoute) 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) 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) MemoryLaneScreen(onNavigate = navigateRoute)
} }
composable(route = AppRoute.WAITING_FOR_PARTNER) { composable(route = AppRoute.WAITING_FOR_PARTNER) {

View File

@ -1,9 +1,10 @@
package app.closer.core.notifications 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.AuthRepository
import app.closer.domain.repository.UserRepository 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.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@ -11,9 +12,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Calendar
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -21,13 +20,13 @@ class AppMessagingService : FirebaseMessagingService() {
@Inject lateinit var authRepository: AuthRepository @Inject lateinit var authRepository: AuthRepository
@Inject lateinit var userRepository: UserRepository @Inject lateinit var userRepository: UserRepository
@Inject lateinit var partnerNotificationManager: PartnerNotificationManager
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val Context.dataStore by preferencesDataStore(name = "settings")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NotificationHelper.createChannels(this) NotificationChannelSetup.createChannels(this)
} }
override fun onDestroy() { override fun onDestroy() {
@ -54,86 +53,22 @@ class AppMessagingService : FirebaseMessagingService() {
} }
override fun onMessageReceived(message: RemoteMessage) { override fun onMessageReceived(message: RemoteMessage) {
val coupleId = message.data["couple_id"] ?: return
val type = message.data["type"] ?: return
serviceScope.launch { serviceScope.launch {
runCatching { runCatching {
val prefs = dataStore.data.first() partnerNotificationManager.handleRemote(
val quietHoursEnabled = prefs[androidx.datastore.preferences.core.booleanPreferencesKey("quiet_hours")] ?: false type = type,
val startHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_hour")] ?: 22 coupleId = coupleId,
val startMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_start_minute")] ?: 0 payload = PartnerNotificationPayload(
val endHour = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_hour")] ?: 8 questionId = message.data["question_id"],
val endMinute = prefs[androidx.datastore.preferences.core.intPreferencesKey("quiet_hours_end_minute")] ?: 0 gameSessionId = message.data["game_session_id"],
capsuleId = message.data["capsule_id"],
if (quietHoursEnabled && isInQuietHours(startHour, startMinute, endHour, endMinute)) { challengeId = message.data["challenge_id"]
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
) )
} }
} }
} }
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 package app.closer.di
import app.closer.core.notifications.NotificationPermissionHelper import android.content.Context
import app.closer.core.notifications.TokenRegistrar 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 com.google.firebase.messaging.FirebaseMessaging
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@ -16,4 +20,28 @@ object NotificationModule {
@Provides @Provides
@Singleton @Singleton
fun provideFirebaseMessaging(): FirebaseMessaging = FirebaseMessaging.getInstance() 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 val onRefresh: () -> Unit
) )
private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action -> private fun HomeCallbacks.toActionHandler(onNavigate: (String) -> Unit): (HomeAction) -> Unit = { action ->
when (action.target) { when (action.target) {
HomeActionTarget.InvitePartner -> onInvite() HomeActionTarget.InvitePartner -> onInvite()
HomeActionTarget.DailyQuestion -> onDailyQuestion() HomeActionTarget.DailyQuestion -> onDailyQuestion()
@ -138,10 +138,10 @@ private fun HomeCallbacks.toActionHandler(): (HomeAction) -> Unit = { action ->
HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks() HomeActionTarget.QuestionPacks -> action.categoryId?.let(onCategory) ?: onPacks()
HomeActionTarget.Settings -> onSettings() HomeActionTarget.Settings -> onSettings()
HomeActionTarget.AnswerReveal -> onReveal() HomeActionTarget.AnswerReveal -> onReveal()
HomeActionTarget.Game -> onPacks() HomeActionTarget.Game -> onNavigate(AppRoute.PLAY)
HomeActionTarget.Challenge -> onPacks() HomeActionTarget.Challenge -> onNavigate(AppRoute.CONNECTION_CHALLENGES)
HomeActionTarget.DatePlan -> onPacks() HomeActionTarget.DatePlan -> onNavigate(AppRoute.DATE_MATCHES)
HomeActionTarget.MemoryCapsule -> onPacks() HomeActionTarget.MemoryCapsule -> onNavigate(AppRoute.MEMORY_LANE)
} }
} }
@ -179,7 +179,7 @@ private fun HomeContent(
onRefresh = onRefresh onRefresh = onRefresh
) )
} }
val onActionSelected = callbacks.toActionHandler() val onActionSelected = callbacks.toActionHandler { route -> onNavigate(route) }
val onPendingActionSelected: (PendingActionCard) -> Unit = { card -> val onPendingActionSelected: (PendingActionCard) -> Unit = { card ->
card.action() card.action()
callbacks.onPendingAction(card) callbacks.onPendingAction(card)
@ -241,7 +241,18 @@ private fun HomeContent(
onAction = onActionSelected 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( CategoryPreviewGrid(
categories = state.categories, 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 @Composable
private fun HomePill(label: String) { private fun HomePill(label: String) {
CloserPill( CloserPill(

View File

@ -16,6 +16,8 @@ import app.closer.domain.repository.QuestionRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import dagger.hilt.android.lifecycle.HiltViewModel 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 javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -323,15 +325,169 @@ class HomeViewModel @Inject constructor(
return copy(primaryAction = null, secondaryActions = emptyList(), pendingActions = emptyList()) 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() val pending = buildPendingActions()
return copy( return copy(
primaryAction = primary, primaryAction = primary,
secondaryActions = buildSecondaryActions(primary), secondaryActions = secondary.take(3),
pendingActions = pending.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> { private fun HomeUiState.buildPendingActions(): List<PendingActionCard> {
if (!isPaired) return emptyList() if (!isPaired) return emptyList()
@ -420,147 +576,6 @@ class HomeViewModel @Inject constructor(
return false 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 = private fun String.toHomeLabel(): String =
split("_", "-") split("_", "-")
.filter { part -> part.isNotBlank() } .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))
}
}