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:
parent
9828e73171
commit
935aee5ec5
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<PendingActionCard> {
|
||||
if (!isPaired) return emptyList()
|
||||
|
||||
|
|
@ -420,147 +576,6 @@ class HomeViewModel @Inject constructor(
|
|||
return false
|
||||
}
|
||||
|
||||
|
||||
|
||||
private fun HomeUiState.buildPrimaryAction(): HomeAction {
|
||||
val dailyQuestionId = dailyQuestion?.id
|
||||
val userAnswered = dailyQuestionId != null && dailyQuestionId in answerStats.answeredQuestionIds
|
||||
val userRevealed = dailyQuestionId != null && answerStats.latest?.let { latest ->
|
||||
latest.questionId == dailyQuestionId && latest.isRevealed
|
||||
} == true
|
||||
|
||||
return when {
|
||||
!isPaired -> HomeAction(
|
||||
eyebrow = "Next best action",
|
||||
title = "Invite your partner into tonight.",
|
||||
body = "The app works best as a shared ritual. Send a private invite and make the next prompt something you can both answer.",
|
||||
cta = "Invite partner",
|
||||
target = HomeActionTarget.InvitePartner,
|
||||
tone = HomeActionTone.Invite
|
||||
)
|
||||
|
||||
userRevealed -> HomeAction(
|
||||
eyebrow = "Tonight's prompt",
|
||||
title = "You opened a conversation tonight.",
|
||||
body = dailyQuestion?.text ?: "You revealed an answer together. What comes next is up to both of you.",
|
||||
cta = "Try a follow-up",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Daily,
|
||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||
)
|
||||
|
||||
dailyQuestionState == DailyQuestionState.BOTH_ANSWERED -> HomeAction(
|
||||
eyebrow = "Tonight's prompt",
|
||||
title = "Reveal is ready.",
|
||||
body = "Both of you answered. Open it together when you are both in the right headspace.",
|
||||
cta = "Reveal together",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Daily,
|
||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||
)
|
||||
|
||||
dailyQuestionState == DailyQuestionState.USER_ANSWERED_PARTNER_PENDING -> HomeAction(
|
||||
eyebrow = "Tonight's prompt",
|
||||
title = "You showed up tonight. Waiting for your partner.",
|
||||
body = "Your answer is private until they answer too. No pressure — the reveal waits for both of you.",
|
||||
cta = "Send a gentle reminder",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Daily,
|
||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||
)
|
||||
|
||||
dailyQuestionState == DailyQuestionState.PARTNER_ANSWERED_USER_PENDING -> HomeAction(
|
||||
eyebrow = "Tonight's prompt",
|
||||
title = "Your partner answered. Your turn.",
|
||||
body = "Answer to unlock the reveal. Your response stays private until you are ready.",
|
||||
cta = "Answer to unlock reveal",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Daily,
|
||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||
)
|
||||
|
||||
userAnswered -> HomeAction(
|
||||
eyebrow = "Tonight's prompt",
|
||||
title = dailyQuestion?.text ?: "Answer tonight's question.",
|
||||
body = "Start with one honest answer. You can keep it private or reveal it when the moment feels right.",
|
||||
cta = "Answer now",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Daily,
|
||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||
)
|
||||
|
||||
answerStats.private > 0 -> HomeAction(
|
||||
eyebrow = "Saved privately",
|
||||
title = "You have ${answerStats.private} reflection${if (answerStats.private == 1) "" else "s"} waiting.",
|
||||
body = "Review what you saved and choose whether tonight is the right time to open one up.",
|
||||
cta = "Review reflections",
|
||||
target = HomeActionTarget.AnswerHistory,
|
||||
tone = HomeActionTone.Reflection,
|
||||
metric = "${answerStats.revealed} revealed"
|
||||
)
|
||||
|
||||
streakCount > 0 -> HomeAction(
|
||||
eyebrow = "Shared ritual",
|
||||
title = "$streakCount night${if (streakCount == 1) "" else "s"} showing up.",
|
||||
body = "Keep it light: answer one prompt, revisit a saved reflection, or choose a pack that fits tonight.",
|
||||
cta = "Keep going",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Ritual,
|
||||
metric = "${answerStats.total} saved"
|
||||
)
|
||||
|
||||
else -> HomeAction(
|
||||
eyebrow = "Gentle start",
|
||||
title = "Tonight's question is ready.",
|
||||
body = "${dailyQuestion?.text ?: "A small prompt is enough. Build the habit around attention, not pressure."}",
|
||||
cta = "Answer privately",
|
||||
target = HomeActionTarget.DailyQuestion,
|
||||
tone = HomeActionTone.Starter,
|
||||
metric = dailyQuestion?.category?.takeIf { category -> category.isNotBlank() }?.toHomeLabel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun HomeUiState.buildSecondaryActions(primary: HomeAction): List<HomeAction> {
|
||||
val actions = mutableListOf<HomeAction>()
|
||||
|
||||
answerStats.latest?.let { latest ->
|
||||
if (primary.target != HomeActionTarget.AnswerHistory) {
|
||||
actions += HomeAction(
|
||||
eyebrow = if (latest.isRevealed) "Revealed" else "Private",
|
||||
title = "Return to your latest reflection.",
|
||||
body = latest.questionText,
|
||||
cta = "Open history",
|
||||
target = HomeActionTarget.AnswerHistory,
|
||||
tone = HomeActionTone.Reflection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
categories.firstOrNull()?.let { category ->
|
||||
actions += HomeAction(
|
||||
eyebrow = "Suggested pack",
|
||||
title = category.category.displayName.ifBlank { "Question pack" },
|
||||
body = "${category.questionCount} prompts for when you want a different doorway into the conversation.",
|
||||
cta = "Open pack",
|
||||
target = HomeActionTarget.QuestionPacks,
|
||||
tone = HomeActionTone.Pack,
|
||||
categoryId = category.category.id
|
||||
)
|
||||
}
|
||||
|
||||
actions += HomeAction(
|
||||
eyebrow = "Tune the ritual",
|
||||
title = "Adjust your space.",
|
||||
body = "Manage reminders, partner state, privacy, and account details when you need to.",
|
||||
cta = "Settings",
|
||||
target = HomeActionTarget.Settings,
|
||||
tone = HomeActionTone.Utility
|
||||
)
|
||||
|
||||
return actions.take(3)
|
||||
}
|
||||
|
||||
private fun String.toHomeLabel(): String =
|
||||
split("_", "-")
|
||||
.filter { part -> part.isNotBlank() }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue