diff --git a/README.md b/README.md index ae244632e1..f74fb69e02 100644 --- a/README.md +++ b/README.md @@ -616,9 +616,3 @@ protocol alongside us — this project is built on it.

![Repobeats analytics image](https://repobeats.axiom.co/api/embed/97acba228c083adca8453a1ebf15f18dad2894be.svg "Repobeats analytics image") - -### Star history - -If NOOP's useful to you, a ⭐ genuinely helps it reach more WHOOP users — and it's the single best free way to support the project. - -[![Star History Chart](https://api.star-history.com/svg?repos=ryanbr/noop&type=Date)](https://star-history.com/#ryanbr/noop&Date) diff --git a/android/app/src/main/java/com/noop/notif/CallAlertController.kt b/android/app/src/main/java/com/noop/notif/CallAlertController.kt index 08c62a3beb..ef8e8a4595 100644 --- a/android/app/src/main/java/com/noop/notif/CallAlertController.kt +++ b/android/app/src/main/java/com/noop/notif/CallAlertController.kt @@ -12,16 +12,14 @@ internal enum class CallAlertSource { } /** - * Shared call-buzz coordinator for native phone state and strict VoIP notifications. - * Multiple sources can be active at once, but only one throttled repeat loop drives the strap. + * One local scheduler for all active calls. + * + * Phone and VoIP notifications can arrive from different Android callbacks. They therefore share + * one token set and one haptic scheduler so an overlapping call cannot create two independent + * reminder loops. No call number, contact, or notification body is retained here. */ internal object CallAlertController { - /** Hard ceiling on how long one call cycle can hold a token. A stop event isn't guaranteed — a - * PHONE_STATE=IDLE broadcast can be dropped, and a VoIP notification can be removed without - * onNotificationRemoved. Without this, a leaked token makes `start()` see `wasInactive = false` - * forever and silently swallow the NEXT call's alert until a process restart. Auto-clear after - * this window (re-armed on each sign of life from a call source). */ - private const val MAX_RING_WINDOW_MS = 60_000L + private const val MAX_RING_WINDOW_MS = 5 * 60_000L private val handler = Handler(Looper.getMainLooper()) private val policy = CallAlertPolicy() @@ -32,22 +30,27 @@ internal object CallAlertController { private val repeatRunnable = object : Runnable { override fun run() { - val ctx = appContext ?: return - maybeBuzz(ctx) + appContext?.let(::maybeBuzz) } } - /** Self-heal: clear all sources if a stop event was missed (see [MAX_RING_WINDOW_MS]). */ + /** Self-heals if Android misses a call-ended or notification-removed callback. */ private val maxRingRunnable = Runnable { stopAll() } + @Synchronized fun start(context: Context, source: CallAlertSource, key: String = source.name): Boolean { if (!sourceEnabled(context, source)) return false + appContext = context.applicationContext + val token = "${source.name}:$key" val wasInactive = activeTokens.isEmpty() - activeTokens.add("${source.name}:$key") - // (Re)arm the self-heal watchdog on every sign of life so a leaked token can't wedge the feature. + activeTokens.add(token) + + // Re-arm the watchdog on every source update. The watchdog only clears the call state; + // it never sends a haptic itself. handler.removeCallbacks(maxRingRunnable) handler.postDelayed(maxRingRunnable, MAX_RING_WINDOW_MS) + if (wasInactive) { buzzCount = 0 lastBuzzAtMs = null @@ -57,16 +60,19 @@ internal object CallAlertController { return true } + @Synchronized fun stop(source: CallAlertSource, key: String = source.name) { activeTokens.remove("${source.name}:$key") if (activeTokens.isEmpty()) resetLoop() } + @Synchronized fun stopSource(source: CallAlertSource) { activeTokens.removeAll { it.startsWith("${source.name}:") } if (activeTokens.isEmpty()) resetLoop() } + @Synchronized fun stopAll() { activeTokens.clear() resetLoop() @@ -74,15 +80,25 @@ internal object CallAlertController { private fun maybeBuzz(context: Context) { pruneDisabledSources(context) - val active = activeTokens.isNotEmpty() + if (activeTokens.isEmpty()) return + val now = System.currentTimeMillis() - if (!policy.shouldBuzz(active, buzzCount, lastBuzzAtMs, now)) return - if (!deliveryAllowed(context)) { + if (!policy.shouldBuzz(true, buzzCount, lastBuzzAtMs, now)) return + + val ble = (context.applicationContext as? NoopApplication)?.ble ?: run { scheduleNext() return } - val ble = (context.applicationContext as? NoopApplication)?.ble ?: return + // A live-HR shortcut can report `bonded` without having the encrypted WHOOP command link. + // Haptics require the genuine encrypted bond, otherwise the command is silently lost. + if (!deliveryAllowed(context, ble)) { + scheduleNext() + return + } + + // Keep all hardware-specific haptic encoding inside WhoopBleClient.buzz(). The call layer + // must never duplicate WHOOP 4 vs 5/MG opcodes or packet framing. ble.buzz(NotifPrefs.callLoops(context)) buzzCount += 1 lastBuzzAtMs = now @@ -123,12 +139,17 @@ internal object CallAlertController { } } - private fun deliveryAllowed(context: Context): Boolean { + private fun deliveryAllowed( + context: Context, + ble: com.noop.ble.WhoopBleClient, + ): Boolean { if (!NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) return false if (!NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) return false if (NotifPrefs.inQuietHours(context)) return false - val ble = (context.applicationContext as? NoopApplication)?.ble ?: return false - if (NotifPrefs.getBool(context, NotifPrefs.WORN, true) && !ble.state.value.worn) return false + + val state = ble.state.value + if (!state.connected || !state.encryptedBond) return false + if (NotifPrefs.getBool(context, NotifPrefs.WORN, true) && !state.worn) return false return true } } diff --git a/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt b/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt index 46da87aaf0..a54ffdb7de 100644 --- a/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt +++ b/android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt @@ -1,14 +1,21 @@ package com.noop.notif -/** - * Small pure policy for repeated call buzzes. The controller owns Android scheduling; - * this object keeps the cadence testable. - */ +/** Pure, deterministic cadence rules for incoming-call haptics. */ internal data class CallAlertPolicy( - val repeatIntervalMs: Long = 8_000L, - val maxBuzzes: Int = 4, + val repeatIntervalMs: Long = 6_000L, + val maxBuzzes: Int = 6, ) { - fun shouldBuzz(active: Boolean, buzzCount: Int, lastBuzzAtMs: Long?, nowMs: Long): Boolean { + init { + require(repeatIntervalMs > 0) { "repeatIntervalMs must be positive" } + require(maxBuzzes >= 1) { "maxBuzzes must be at least 1" } + } + + fun shouldBuzz( + active: Boolean, + buzzCount: Int, + lastBuzzAtMs: Long?, + nowMs: Long, + ): Boolean { if (!active || buzzCount >= maxBuzzes) return false return lastBuzzAtMs == null || nowMs - lastBuzzAtMs >= repeatIntervalMs } diff --git a/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt b/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt index 457e04b947..ba002db992 100644 --- a/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt +++ b/android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt @@ -1,12 +1,15 @@ package com.noop.notif import android.app.Notification +import android.os.Build /** - * Strict VoIP call detection for NotificationListenerService. + * Strict, privacy-preserving VoIP call detection for NotificationListenerService. * - * The classifier intentionally uses only package name, category, and flags. It does not - * inspect title, text, people, sender, or extras that may contain caller/message content. + * We intentionally inspect only package identity, notification category/flags, and the public + * Notification.CallStyle type. We never inspect title, text, people, caller names, or extras. + * Android documents CATEGORY_CALL as the category for incoming voice/video calls, while CallStyle + * is the platform's dedicated call notification style on API 31+. */ internal object VoipCallClassifier { const val CATEGORY_CALL = "call" @@ -20,6 +23,8 @@ internal object VoipCallClassifier { "com.google.android.apps.tachyon", "com.google.android.apps.meetings", "com.facebook.orca", + "com.discord", + "com.instagram.android", ) data class Metadata( @@ -27,17 +32,17 @@ internal object VoipCallClassifier { val isOngoing: Boolean, val isForegroundService: Boolean, val isGroupSummary: Boolean, + val isCallStyle: Boolean, ) - fun isKnownVoipPackage(packageName: String): Boolean = - packageName in knownVoipPackages + fun isKnownVoipPackage(packageName: String): Boolean = packageName in knownVoipPackages fun isIncomingCallNotification(packageName: String, metadata: Metadata): Boolean { if (!isKnownVoipPackage(packageName)) return false - if (metadata.isForegroundService || metadata.isGroupSummary) return false - // Do not read CallStyle extras here: this trades VoIP recall for the documented - // wrist-alert privacy contract that notification extras are not inspected. - return metadata.category == CATEGORY_CALL && !metadata.isOngoing + if (metadata.isForegroundService || metadata.isGroupSummary || metadata.isOngoing) return false + // Prefer Android's explicit CallStyle signal when available; otherwise fall back to the + // documented CATEGORY_CALL contract used by older Android and third-party VoIP apps. + return metadata.isCallStyle || metadata.category == CATEGORY_CALL } fun metadataOf(notification: Notification, isOngoing: Boolean): Metadata = @@ -46,5 +51,6 @@ internal object VoipCallClassifier { isOngoing = isOngoing, isForegroundService = (notification.flags and Notification.FLAG_FOREGROUND_SERVICE) != 0, isGroupSummary = (notification.flags and Notification.FLAG_GROUP_SUMMARY) != 0, + isCallStyle = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && notification.style is Notification.CallStyle, ) } diff --git a/android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt b/android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt new file mode 100644 index 0000000000..3348bb6765 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt @@ -0,0 +1,109 @@ +package com.noop.ui + +import android.content.Context +import java.util.Calendar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.filled.Alarm +import androidx.compose.material.icons.filled.CalendarMonth +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Groups +import androidx.compose.material.icons.filled.Message +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.ui.graphics.vector.ImageVector + +/** Haptic shapes exposed to the notification UI. */ +internal enum class BuzzPattern(val label: String, val loops: Int) { + Single("Single", 1), + Double("Double", 2), + Triple("Triple", 3), + Long("Long", 5), +} + +internal enum class NotifCategory( + val title: String, + val icon: ImageVector, + val defaultPattern: BuzzPattern, +) { + Calls("Calls", Icons.Filled.Message, BuzzPattern.Triple), + Messaging("Messages", Icons.AutoMirrored.Filled.Chat, BuzzPattern.Single), + Email("Email", Icons.Filled.Email, BuzzPattern.Double), + Meetings("Meetings", Icons.Filled.Videocam, BuzzPattern.Triple), + Calendar("Calendar", Icons.Filled.CalendarMonth, BuzzPattern.Double), +} + +internal data class NotifApp( + val id: String, + val name: String, + val category: NotifCategory, + val glyph: ImageVector, +) + +internal val notifCatalog: List = listOf( + NotifApp("com.whatsapp", "WhatsApp", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), + NotifApp("org.telegram.messenger", "Telegram", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), + NotifApp("com.google.android.apps.messaging", "Messages", NotifCategory.Messaging, Icons.Filled.Message), + NotifApp("com.Slack", "Slack", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), + NotifApp("com.microsoft.teams", "Microsoft Teams", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), + NotifApp("com.google.android.gm", "Gmail", NotifCategory.Email, Icons.Filled.Email), + NotifApp("com.microsoft.office.outlook", "Outlook", NotifCategory.Email, Icons.Filled.Email), + NotifApp("us.zoom.videomeetings", "Zoom", NotifCategory.Meetings, Icons.Filled.Videocam), + NotifApp("com.google.android.calendar", "Calendar", NotifCategory.Calendar, Icons.Filled.CalendarMonth), +) + +internal object NotifPrefs { + private const val FILE = "noop_notif_prefs" + const val MASTER = "notif.masterEnabled" + const val ALL_OTHER = "notif.allOtherApps" + const val WORN = "notif.onlyWhenWorn" + const val QUIET = "notif.quietHoursEnabled" + const val QUIET_START = "notif.quietStartMinutes" + const val QUIET_END = "notif.quietEndMinutes" + const val CALLS_MASTER = "notif.calls.masterEnabled" + const val CALLS_PHONE = "notif.calls.phoneEnabled" + const val CALLS_VOIP = "notif.calls.voipEnabled" + const val CALLS_PATTERN = "notif.calls.pattern" + const val ALARM_TIMER = "notif.alarmTimer" + + private fun prefs(ctx: Context) = ctx.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) + + fun getBool(ctx: Context, key: String, default: Boolean) = prefs(ctx).getBoolean(key, default) + fun setBool(ctx: Context, key: String, value: Boolean) = prefs(ctx).edit().putBoolean(key, value).apply() + fun getInt(ctx: Context, key: String, default: Int) = prefs(ctx).getInt(key, default) + fun setInt(ctx: Context, key: String, value: Int) = prefs(ctx).edit().putInt(key, value).apply() + + fun appEnabled(ctx: Context, id: String) = prefs(ctx).getBoolean("app.$id.enabled", false) + fun setAppEnabled(ctx: Context, id: String, value: Boolean) = prefs(ctx).edit().putBoolean("app.$id.enabled", value).apply() + + fun appPattern(ctx: Context, app: NotifApp): BuzzPattern { + val saved = prefs(ctx).getString("app.${app.id}.pattern", null) + return BuzzPattern.entries.firstOrNull { it.name == saved } ?: app.category.defaultPattern + } + + fun setAppPattern(ctx: Context, id: String, pattern: BuzzPattern) = + prefs(ctx).edit().putString("app.$id.pattern", pattern.name).apply() + + fun appLoops(ctx: Context, pkg: String): Int { + val saved = prefs(ctx).getString("app.$pkg.pattern", null) + return BuzzPattern.entries.firstOrNull { it.name == saved }?.loops ?: BuzzPattern.Double.loops + } + + fun callPattern(ctx: Context): BuzzPattern { + val saved = prefs(ctx).getString(CALLS_PATTERN, null) + return BuzzPattern.entries.firstOrNull { it.name == saved } ?: BuzzPattern.Triple + } + + fun setCallPattern(ctx: Context, pattern: BuzzPattern) = + prefs(ctx).edit().putString(CALLS_PATTERN, pattern.name).apply() + + fun callLoops(ctx: Context) = callPattern(ctx).loops + + fun inQuietHours(ctx: Context): Boolean { + if (!getBool(ctx, QUIET, false)) return false + val start = getInt(ctx, QUIET_START, 22 * 60) + val end = getInt(ctx, QUIET_END, 7 * 60) + val nowCal = Calendar.getInstance() + val now = nowCal.get(Calendar.HOUR_OF_DAY) * 60 + nowCal.get(Calendar.MINUTE) + return if (start <= end) now in start until end else now >= start || now < end + } +} diff --git a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt index cb19ad715f..b552d16ac3 100644 --- a/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt +++ b/android/app/src/main/java/com/noop/ui/NotificationsSettingsScreen.kt @@ -1,7 +1,5 @@ package com.noop.ui -import com.noop.R -import androidx.compose.ui.res.stringResource import android.Manifest import android.content.Context import android.content.Intent @@ -18,34 +16,28 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CalendarMonth -import androidx.compose.material.icons.filled.Alarm import androidx.compose.material.icons.filled.Call -import androidx.compose.material.icons.automirrored.filled.Chat -import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.GraphicEq -import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.NotificationsActive -import androidx.compose.material.icons.automirrored.filled.OpenInNew -import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.OpenInNew +import androidx.compose.material.icons.filled.Phone +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Tune -import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material.icons.filled.Watch import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text -import androidx.compose.material3.TimePicker -import androidx.compose.material3.TimePickerDefaults -import androidx.compose.material3.rememberTimePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateMapOf @@ -59,1000 +51,304 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.noop.ble.WhoopModel import com.noop.notif.CallAlertController import com.noop.notif.CallAlertSource -import java.util.Calendar - -// MARK: - NotificationsSettingsScreen -// -// Android port of NotificationSettingsView.swift. Choose which apps tap your wrist and -// how (per-app buzz pattern), with a master switch and overnight quiet hours. -// -// macOS resolves real installed apps via LaunchServices/NSWorkspace. Android restricts -// package visibility (API 30+) and there is no equivalent "notification-capable app" -// query, so we ship a curated catalog of common notification apps grouped exactly like -// the Mac screen. Preferences persist in SharedPreferences (the Android counterpart to -// UserDefaults); when the background bridge ships it reads the same prefs. -// -// Delivery requires a NotificationListenerService with Notification Access granted — the -// behaviour card deep-links to Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS for that. - -// MARK: - Domain model (mirrors NotificationSettingsStore.swift) - -/** Haptic pattern fired on the strap; only the repeat count varies. */ -internal enum class BuzzPattern(val label: String, val loops: Int) { - Single("Single", 1), - Double("Double", 2), - Triple("Triple", 3), - Long("Long", 5), -} - -/** Grouping for the settings screen, with its header icon + default pattern. */ -internal enum class NotifCategory( - val title: String, - val icon: ImageVector, - val defaultPattern: BuzzPattern, -) { - Email("Email", Icons.Filled.Email, BuzzPattern.Double), - Messaging("Messaging", Icons.AutoMirrored.Filled.Chat, BuzzPattern.Single), - Meetings("Meetings", Icons.Filled.Videocam, BuzzPattern.Triple), - Calendar("Calendar & Reminders", Icons.Filled.CalendarMonth, BuzzPattern.Double), -} - -/** A notification-capable app NOOP can mirror to the wrist. `id` is the persistence key. */ -internal data class NotifApp( - val id: String, - val name: String, - val category: NotifCategory, - val glyph: ImageVector, -) - -/** - * Curated catalog of common Android notification apps, grouped to match the Mac screen. - * Unlike macOS we cannot enumerate which are actually installed (restricted package - * visibility), so we present the full set as configurable examples. - */ -private val notifCatalog: List = listOf( - NotifApp("com.google.android.gm", "Gmail", NotifCategory.Email, Icons.Filled.Email), - NotifApp("com.microsoft.office.outlook", "Outlook", NotifCategory.Email, Icons.Filled.Email), - NotifApp("com.whatsapp", "WhatsApp", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), - NotifApp("com.google.android.apps.messaging", "Messages", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), - NotifApp("com.Slack", "Slack", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), - NotifApp("org.telegram.messenger", "Telegram", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), - // Teams' ringing-call notifications are handled by the Calls card below (VoIP path). This - // per-app row covers everything else Teams sends to the shade (chats, @-mentions, channel - // posts), which read as messages, so it lives under Messaging with the chat glyph. - NotifApp("com.microsoft.teams", "Microsoft Teams", NotifCategory.Messaging, Icons.AutoMirrored.Filled.Chat), - NotifApp("us.zoom.videomeetings", "Zoom", NotifCategory.Meetings, Icons.Filled.Videocam), - NotifApp("com.google.android.calendar", "Calendar", NotifCategory.Calendar, Icons.Filled.CalendarMonth), -) - -private fun appsIn(category: NotifCategory): List = - notifCatalog.filter { it.category == category } - -private val activeCategories: List = - NotifCategory.entries.filter { appsIn(it).isNotEmpty() } - -// MARK: - SharedPreferences store (mirrors the UserDefaults-backed Swift store) - -/** - * Plain-prefs store for wrist-alert settings (the AI key uses encrypted prefs; these are - * non-secret toggles). Per-app prefs are flattened to `app..enabled` / `app..pattern` - * keys so no JSON dependency is needed. - */ -internal object NotifPrefs { - private const val FILE = "noop_notif_prefs" - const val MASTER = "notif.masterEnabled" - /** Catch-all: buzz for any app NOT in the curated catalog (Android can't enumerate installed - * apps, so this is how a user covers BeReal/etc. that aren't listed). Opt-in, default OFF. (#168) */ - const val ALL_OTHER = "notif.allOtherApps" - const val WORN = "notif.onlyWhenWorn" - const val QUIET = "notif.quietHoursEnabled" - const val QUIET_START = "notif.quietStartMinutes" - const val QUIET_END = "notif.quietEndMinutes" - const val CALLS_MASTER = "notif.calls.masterEnabled" - const val CALLS_PHONE = "notif.calls.phoneEnabled" - const val CALLS_VOIP = "notif.calls.voipEnabled" - const val CALLS_PATTERN = "notif.calls.pattern" - /** Buzz the strap when the phone's native Clock fires a timer/alarm (CATEGORY_ALARM). Android-only - * (iOS can't observe another app's notifications). Default OFF. */ - const val ALARM_TIMER = "notif.alarmTimer" - - private fun prefs(ctx: Context) = - ctx.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE) - - fun getBool(ctx: Context, key: String, default: Boolean) = - prefs(ctx).getBoolean(key, default) - - fun setBool(ctx: Context, key: String, value: Boolean) = - prefs(ctx).edit().putBoolean(key, value).apply() - - fun getInt(ctx: Context, key: String, default: Int) = - prefs(ctx).getInt(key, default) - - fun setInt(ctx: Context, key: String, value: Int) = - prefs(ctx).edit().putInt(key, value).apply() - - fun appEnabled(ctx: Context, id: String): Boolean = - prefs(ctx).getBoolean("app.$id.enabled", false) // opt-in, default OFF - - fun setAppEnabled(ctx: Context, id: String, value: Boolean) = - prefs(ctx).edit().putBoolean("app.$id.enabled", value).apply() - - fun appPattern(ctx: Context, app: NotifApp): BuzzPattern { - val name = prefs(ctx).getString("app.${app.id}.pattern", null) - return BuzzPattern.entries.firstOrNull { it.name == name } ?: app.category.defaultPattern - } - - fun setAppPattern(ctx: Context, id: String, pattern: BuzzPattern) = - prefs(ctx).edit().putString("app.$id.pattern", pattern.name).apply() - - /** Buzz loop-count for [pkg] (for the notification listener; no NotifApp needed). Defaults to - * Double if no per-app pattern was chosen. */ - fun appLoops(ctx: Context, pkg: String): Int { - val name = prefs(ctx).getString("app.$pkg.pattern", null) - return BuzzPattern.entries.firstOrNull { it.name == name }?.loops ?: BuzzPattern.Double.loops - } - - fun callPattern(ctx: Context): BuzzPattern { - val name = prefs(ctx).getString(CALLS_PATTERN, null) - return BuzzPattern.entries.firstOrNull { it.name == name } ?: BuzzPattern.Triple - } - - fun setCallPattern(ctx: Context, pattern: BuzzPattern) = - prefs(ctx).edit().putString(CALLS_PATTERN, pattern.name).apply() - - fun callLoops(ctx: Context): Int = callPattern(ctx).loops - - fun inQuietHours(ctx: Context): Boolean { - if (!getBool(ctx, QUIET, false)) return false - val start = getInt(ctx, QUIET_START, 22 * 60) - val end = getInt(ctx, QUIET_END, 7 * 60) - val cal = Calendar.getInstance() - val now = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) - // Quiet window may wrap midnight (e.g. 22:00 -> 07:00). - return if (start <= end) now in start until end else (now >= start || now < end) - } -} - -// MARK: - Screen @Composable fun NotificationsSettingsScreen(vm: AppViewModel) { val context = LocalContext.current val live by vm.live.collectAsStateWithLifecycle() - - // Header settings, seeded from prefs once and written through on change. - var masterEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) } - var onlyWhenWorn by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.WORN, true)) } - var allOtherApps by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALL_OTHER, false)) } - var quietHoursEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.QUIET, false)) } - var quietStartMinutes by remember { mutableStateOf(NotifPrefs.getInt(context, NotifPrefs.QUIET_START, 22 * 60)) } - var quietEndMinutes by remember { mutableStateOf(NotifPrefs.getInt(context, NotifPrefs.QUIET_END, 7 * 60)) } - var callsEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) } - var phoneCallsEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_PHONE, false)) } - var voipCallsEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_VOIP, false)) } - var alarmTimerEnabled by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALARM_TIMER, false)) } - var callsPattern by remember { mutableStateOf(NotifPrefs.callPattern(context)) } - // Scheduled report notifications (#517) — opt-in, default OFF. SharedPreferences isn't reactive, so - // each Switch mirrors into local state and writes straight through to NoopPrefs. - var morningReport by remember { mutableStateOf(NoopPrefs.morningReportEnabled(context)) } - var postWorkoutReport by remember { mutableStateOf(NoopPrefs.postWorkoutReportEnabled(context)) } - var strainTargetReport by remember { mutableStateOf(NoopPrefs.strainTargetEnabled(context)) } - var phonePermissionDenied by remember { mutableStateOf(false) } - val phonePermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> - phoneCallsEnabled = granted - phonePermissionDenied = !granted + var master by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.MASTER, false)) } + var calls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_MASTER, false)) } + var phoneCalls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_PHONE, false)) } + var voipCalls by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.CALLS_VOIP, false)) } + var wornOnly by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.WORN, true)) } + var quiet by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.QUIET, false)) } + var alarmTimer by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALARM_TIMER, false)) } + var allOther by remember { mutableStateOf(NotifPrefs.getBool(context, NotifPrefs.ALL_OTHER, false)) } + var callPattern by remember { mutableStateOf(NotifPrefs.callPattern(context)) } + var permissionDenied by remember { mutableStateOf(false) } + var appQuery by remember { mutableStateOf("") } + + val enabledState = remember { mutableStateMapOf().apply { notifCatalog.forEach { put(it.id, NotifPrefs.appEnabled(context, it.id)) } } } + val patternState = remember { mutableStateMapOf().apply { notifCatalog.forEach { put(it.id, NotifPrefs.appPattern(context, it)) } } } + val phonePermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + phoneCalls = granted + permissionDenied = !granted NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, granted) } - - // Per-app enabled state, seeded from prefs so the UI is reactive within the session. - val enabledState: SnapshotStateMap = remember { - mutableStateMapOf().apply { - notifCatalog.forEach { put(it.id, NotifPrefs.appEnabled(context, it.id)) } - } - } - val patternState: SnapshotStateMap = remember { - mutableStateMapOf().apply { - notifCatalog.forEach { put(it.id, NotifPrefs.appPattern(context, it)) } - } - } - val enabledCount = enabledState.values.count { it } - - ScreenScaffold( - title = uiString(R.string.l10n_notifications_settings_screen_notifications_753a22b2), - subtitle = "Buzz your strap when these apps notify you. Everything runs on this device.", - ) { - // MARK: Master card - AlertSection( - icon = Icons.Filled.NotificationsActive, - title = uiString(R.string.l10n_notifications_settings_screen_wrist_alerts_75581d51), - blurb = "When on, NOOP taps your wrist for the apps you pick below, so you can leave " + - "your phone and still feel what matters.", - ) { - Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - Text(uiString(R.string.l10n_notifications_settings_screen_enable_wrist_alerts_462b9e0f), style = NoopType.body, color = Palette.textPrimary) - Spacer(Modifier.weight(1f)) - NoopSwitch( - checked = masterEnabled, - onChange = { - masterEnabled = it - NotifPrefs.setBool(context, NotifPrefs.MASTER, it) - }, - label = uiString(R.string.l10n_notifications_settings_screen_enable_wrist_alerts_462b9e0f), - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - StatePill(strapPillTitle(live), tone = strapPillTone(live), pulsing = live.connected) - StatePill( - "$enabledCount app${if (enabledCount == 1) "" else "s"} on", - tone = if (enabledCount > 0) StrandTone.Positive else StrandTone.Neutral, - showsDot = false, - ) - Spacer(Modifier.weight(1f)) - PillButton( - label = uiString(R.string.l10n_notifications_settings_screen_test_buzz_deeab5ae), - icon = Icons.Filled.GraphicEq, - enabled = live.bonded, - onClick = { vm.buzz(loops = 2) }, - ) - } - - DeliveryNote() - } - - CallsCard( - masterEnabled = masterEnabled, - callsEnabled = callsEnabled, - phoneCallsEnabled = phoneCallsEnabled, - voipCallsEnabled = voipCallsEnabled, - pattern = callsPattern, - bonded = live.bonded, - permissionDenied = phonePermissionDenied, - onCallsEnabled = { - callsEnabled = it - NotifPrefs.setBool(context, NotifPrefs.CALLS_MASTER, it) - if (!it) CallAlertController.stopAll() - }, - onPhoneCallsEnabled = { value -> + val enabledApps = enabledState.values.count { it } + val deliveryReady = master && live.connected && live.encryptedBond && (!wornOnly || live.worn) && !quiet + val notificationAccess = notificationAccessGranted(context) + + ScreenScaffold(title = "Notifications", subtitle = "Make the information you need reach your wrist.") { + NotificationHero(master, live.connected, live.encryptedBond, live.worn, deliveryReady, enabledApps, { + master = it + NotifPrefs.setBool(context, NotifPrefs.MASTER, it) + if (!it) CallAlertController.stopAll() + }) { vm.buzz(loops = 2) } + + CallsControlCard(master, calls, phoneCalls, voipCalls, callPattern, live.connected && live.encryptedBond, permissionDenied, + { calls = it; NotifPrefs.setBool(context, NotifPrefs.CALLS_MASTER, it); if (!it) CallAlertController.stopAll() }, + { value -> if (!value) { - phoneCallsEnabled = false - phonePermissionDenied = false + phoneCalls = false; permissionDenied = false NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, false) CallAlertController.stopSource(CallAlertSource.PHONE) - return@CallsCard - } - val granted = ContextCompat.checkSelfPermission( - context, - Manifest.permission.READ_PHONE_STATE, - ) == PackageManager.PERMISSION_GRANTED - if (granted) { - phoneCallsEnabled = true - phonePermissionDenied = false + } else if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED) { + phoneCalls = true; permissionDenied = false NotifPrefs.setBool(context, NotifPrefs.CALLS_PHONE, true) - } else { - phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) - } + } else phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE) }, - onVoipCallsEnabled = { - voipCallsEnabled = it - NotifPrefs.setBool(context, NotifPrefs.CALLS_VOIP, it) - if (!it) CallAlertController.stopSource(CallAlertSource.VOIP) - }, - onPattern = { - callsPattern = it - NotifPrefs.setCallPattern(context, it) - }, - onTest = { vm.buzz(loops = callsPattern.loops) }, - ) - - // #1115 follow-up: buzz the strap when the phone's native Clock fires a timer or alarm - // (CATEGORY_ALARM, any clock app). Android-only — iOS can't observe another app's notifications. - AlertSection( - icon = Icons.Filled.Alarm, - title = uiString(R.string.notif_timer_alarm_title), - blurb = "Buzz your wrist when your phone's Clock app finishes a timer or rings an alarm.", - ) { - Column(modifier = Modifier.alphaIf(if (masterEnabled) 1f else Palette.disabledOpacity)) { - FormToggleRow( - label = uiString(R.string.notif_timer_alarm_title), - help = "Requires wrist alerts (above) to be on. Buzzes once when a timer/alarm notification fires.", - checked = alarmTimerEnabled, - enabled = masterEnabled, - onChange = { - alarmTimerEnabled = it - NotifPrefs.setBool(context, NotifPrefs.ALARM_TIMER, it) - }, - ) - } - } - - // #926: on a 5/MG the buzz payload is a hardcoded 12-byte literal and byte[11] (overallLoop) is - // 0, so the caller's repeat count never reaches the wire — every BuzzPattern produces the same - // buzz. The picker still offers all four because the choice is stored per app and DOES apply to a - // WHOOP 4.0, so hiding it would lose a real setting. Say so instead, the way SmartAlarmScreen - // handles its own 5/MG-unconfirmed case, rather than leaving a control that silently does nothing. - // Match the Settings #22 gate (SettingsScreen showFiveMGControls, mirrored in TestCentreScreen:87): - // the stored model OR a live-detected 5/MG this session. `live.whoop5Detected` alone is reset on - // every scan and disconnect, so keying on it would hide this note whenever the strap is offline — - // which is exactly when someone browses notification settings. The Apple side reads `selectedModel`, - // which persists, so gating on live detection here would also make the two platforms disagree. - val fiveMgSelected = remember { - NoopPrefs.of(context).getString("noop.selectedWhoopModel", null) == WhoopModel.WHOOP5_MG.name - } - if (fiveMgSelected || live.whoop5Detected) { - Text( - uiString(R.string.l10n_notifications_settings_screen_every_pattern_buzzes_the_same_on_34e873f6), - style = NoopType.caption, - color = Palette.textSecondary, - modifier = Modifier.padding(horizontal = Metrics.space16, vertical = Metrics.space8), - ) - } - - // MARK: Category cards - activeCategories.forEach { cat -> - CategoryCard( - category = cat, - apps = appsIn(cat), - masterEnabled = masterEnabled, - bonded = live.bonded, - enabledState = enabledState, - patternState = patternState, - onToggle = { app, value -> - enabledState[app.id] = value - NotifPrefs.setAppEnabled(context, app.id, value) - }, - onPattern = { app, pattern -> - patternState[app.id] = pattern - NotifPrefs.setAppPattern(context, app.id, pattern) - }, - onTest = { app -> vm.buzz(loops = (patternState[app.id] ?: app.category.defaultPattern).loops) }, - ) - } - - // MARK: Behaviour card - AlertSection( - icon = Icons.Filled.Tune, - title = uiString(R.string.l10n_notifications_settings_screen_behaviour_171ca038), - blurb = "Fine-tune when alerts reach your wrist.", - ) { - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_only_buzz_when_worn_6211cee3), - help = "Skip alerts when the strap is off your wrist.", - checked = onlyWhenWorn, - onChange = { - onlyWhenWorn = it - NotifPrefs.setBool(context, NotifPrefs.WORN, it) - }, - ) - RowDivider() - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_all_other_apps_51a8af2c), - help = "Also buzz for apps that aren't in the lists above (e.g. BeReal). Android " + - "doesn't let NOOP see every installed app, so this is how you cover the rest. " + - "Can be chatty; quiet hours and \"only when worn\" still apply.", - checked = allOtherApps, - onChange = { - allOtherApps = it - NotifPrefs.setBool(context, NotifPrefs.ALL_OTHER, it) - }, - ) - RowDivider() - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_quiet_hours_706b24d0), - help = "Mute wrist alerts overnight.", - checked = quietHoursEnabled, - onChange = { - quietHoursEnabled = it - NotifPrefs.setBool(context, NotifPrefs.QUIET, it) - }, - ) - if (quietHoursEnabled) { - RowDivider() - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(uiString(R.string.l10n_notifications_settings_screen_from_3f66052a), style = NoopType.body, color = Palette.textPrimary) - TimeChip( - minutes = quietStartMinutes, - accessibilityLabel = "Quiet hours start", - onPicked = { - quietStartMinutes = it - NotifPrefs.setInt(context, NotifPrefs.QUIET_START, it) - }, - ) - Text("to", style = NoopType.body, color = Palette.textSecondary) - TimeChip( - minutes = quietEndMinutes, - accessibilityLabel = "Quiet hours end", - onPicked = { - quietEndMinutes = it - NotifPrefs.setInt(context, NotifPrefs.QUIET_END, it) - }, - ) - Spacer(Modifier.weight(1f)) - } - } - } - - // MARK: Daily reports (#517) — phone notifications, not wrist buzzes. Opt-in, default OFF, no AI. - AlertSection( - icon = Icons.Filled.NotificationsActive, - title = uiString(R.string.l10n_notifications_settings_screen_daily_reports_c1a22a74), - blurb = "Optional phone notifications, off by default. These arrive after your strap syncs " + - "and NOOP scores the data, so they land soon after, not the exact second you wake or " + - "finish a workout. Everything is worked out on this phone.", - ) { - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_morning_recap_45ec05c5), - help = "After last night is processed, a notification with your Charge and Rest. Posts " + - "once a day, after your strap has synced the night.", - checked = morningReport, - onChange = { - morningReport = it - NoopPrefs.setMorningReportEnabled(context, it) - }, - ) - RowDivider() - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_post_workout_summary_13e488f5), - help = "When a new workout syncs in, a notification with its Effort, duration and average " + - "heart rate. Shows up after the session reaches NOOP on the next sync.", - checked = postWorkoutReport, - onChange = { - postWorkoutReport = it - NoopPrefs.setPostWorkoutReportEnabled(context, it) - // Seed the frontier to the newest existing workout when turning ON, so enabling it - // doesn't immediately fire a summary for a session already in history. - if (it) vm.seedWorkoutReportFrontier() - }, - ) - RowDivider() - // #593: NOOP's own optimal-strain-reached nudge (not WHOOP's copy). - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_optimal_strain_reached_2862ec2b), - help = "Once a day, a notification when your Effort reaches the low end of today's optimal " + - "strain range (from your recovery). Posts after your strap syncs and NOOP scores the day.", - checked = strainTargetReport, - onChange = { - strainTargetReport = it - NoopPrefs.setStrainTargetEnabled(context, it) - }, - ) - } + { voipCalls = it; NotifPrefs.setBool(context, NotifPrefs.CALLS_VOIP, it); if (!it) CallAlertController.stopSource(CallAlertSource.VOIP) }, + { callPattern = it; NotifPrefs.setCallPattern(context, it) }, + { vm.buzz(loops = callPattern.loops) }) + + AppsControlCard(master, appQuery, { appQuery = it }, enabledState, patternState, + { app, value -> enabledState[app.id] = value; NotifPrefs.setAppEnabled(context, app.id, value) }, + { app, pattern -> patternState[app.id] = pattern; NotifPrefs.setAppPattern(context, app.id, pattern) }, + { app -> vm.buzz(loops = (patternState[app.id] ?: app.category.defaultPattern).loops) }) + + NotificationBehaviourCard(master, wornOnly, quiet, alarmTimer, allOther, + { wornOnly = it; NotifPrefs.setBool(context, NotifPrefs.WORN, it) }, + { quiet = it; NotifPrefs.setBool(context, NotifPrefs.QUIET, it) }, + { alarmTimer = it; NotifPrefs.setBool(context, NotifPrefs.ALARM_TIMER, it) }, + { allOther = it; NotifPrefs.setBool(context, NotifPrefs.ALL_OTHER, it) }) + AccessCard(context, notificationAccess) + DiagnosticsCard(context, master, live.connected, live.encryptedBond, live.worn, notificationAccess, calls && (phoneCalls || voipCalls)) } } -// MARK: - Strap status (mirrors the three-state mapping from the Mac screen) - -private fun strapPillTitle(live: com.noop.ble.LiveState): String = when { - live.connected -> "Strap connected" - live.bonded -> "Strap idle" - else -> "Strap not connected" -} - -private fun strapPillTone(live: com.noop.ble.LiveState): StrandTone = when { - live.connected -> StrandTone.Positive - live.bonded -> StrandTone.Warning - else -> StrandTone.Critical -} - @Composable -private fun CallsCard( - masterEnabled: Boolean, - callsEnabled: Boolean, - phoneCallsEnabled: Boolean, - voipCallsEnabled: Boolean, - pattern: BuzzPattern, - bonded: Boolean, - permissionDenied: Boolean, - onCallsEnabled: (Boolean) -> Unit, - onPhoneCallsEnabled: (Boolean) -> Unit, - onVoipCallsEnabled: (Boolean) -> Unit, - onPattern: (BuzzPattern) -> Unit, - onTest: () -> Unit, -) { - val contentAlpha = if (masterEnabled) 1f else Palette.disabledOpacity - AlertSection( - icon = Icons.Filled.Call, - title = uiString(R.string.l10n_notifications_settings_screen_calls_0a19b7e2), - blurb = "Tap your wrist for incoming phone calls and strict best-effort VoIP calls.", - ) { - Column(modifier = Modifier.alphaIf(contentAlpha)) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(uiString(R.string.l10n_notifications_settings_screen_buzz_on_incoming_calls_625804a1), style = NoopType.body, color = Palette.textPrimary) - Text( - uiString(R.string.l10n_notifications_settings_screen_uses_the_same_quiet_hours_and_03badcac), - style = NoopType.footnote, - color = Palette.textTertiary, - ) - } - if (callsEnabled) { - PatternMenu(pattern = pattern, enabled = masterEnabled, appName = "calls", onSelect = onPattern) - TestIconButton(enabled = masterEnabled && bonded, appName = "calls", onClick = onTest) +private fun NotificationHero(enabled: Boolean, connected: Boolean, encryptedBond: Boolean, worn: Boolean, ready: Boolean, enabledApps: Int, onMasterChange: (Boolean) -> Unit, onTest: () -> Unit) { + NoopCard(padding = 20.dp, tint = if (ready) Palette.accent else Palette.hairline) { + Column(verticalArrangement = Arrangement.spacedBy(18.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Overline("WRIST ALERTS", color = Palette.accent) + Text("Notification bridge", style = NoopType.title2, color = Palette.textPrimary) + Text(when { + !enabled -> "Off — your WHOOP will stay quiet." + !connected -> "Waiting for your WHOOP to connect." + !encryptedBond -> "Connected, but the secure command link is not ready." + !worn -> "Strap connected, but not currently worn." + else -> "Ready — important events can reach your wrist." + }, style = NoopType.subhead, color = Palette.textSecondary) } - NoopSwitch( - checked = callsEnabled, - onChange = onCallsEnabled, - enabled = masterEnabled, - label = uiString(R.string.l10n_notifications_settings_screen_buzz_on_incoming_calls_625804a1), - ) + NoopSwitch(enabled, onMasterChange, true, "Wrist alerts") } - if (callsEnabled) { - RowDivider() - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_phone_calls_b79420d9), - help = "Needs Phone permission; NOOP never reads numbers or call logs.", - checked = phoneCallsEnabled, - enabled = masterEnabled, - onChange = onPhoneCallsEnabled, - ) - if (permissionDenied) { - Text( - uiString(R.string.l10n_notifications_settings_screen_phone_permission_was_denied_so_phone_db0ebdd8), - style = NoopType.footnote, - color = Palette.statusCritical, - modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatusChip(Icons.Filled.Watch, if (connected) "WHOOP connected" else "WHOOP offline", connected) + StatusChip(Icons.Filled.CheckCircle, if (encryptedBond) "Secure link" else "Link not ready", encryptedBond) + StatusChip(Icons.Filled.NotificationsActive, "$enabledApps apps", enabledApps > 0) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Test your wrist", style = NoopType.body, color = Palette.textPrimary) + Text("Sends a short two-pulse test now.", style = NoopType.footnote, color = Palette.textTertiary) } - RowDivider() - FormToggleRow( - label = uiString(R.string.l10n_notifications_settings_screen_voip_calls_96c5a102), - help = "Detects call-style notifications from known calling apps.", - checked = voipCallsEnabled, - enabled = masterEnabled, - onChange = onVoipCallsEnabled, - ) + ActionPill("Test buzz", Icons.Filled.GraphicEq, connected && encryptedBond, onTest) } } } } -// MARK: - Delivery note (Notification Access requirement + deep link) - @Composable -private fun DeliveryNote() { - val context = LocalContext.current - val shape = RoundedCornerShape(10.dp) - Column( - modifier = Modifier - .fillMaxWidth() - .clip(shape) - .background(Palette.surfaceInset) - .border(1.dp, Palette.accent.copy(alpha = 0.22f), shape) - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Icon( - Icons.Filled.Info, - contentDescription = null, - tint = Palette.accent, - modifier = Modifier.size(16.dp), - ) - Text( - uiString(R.string.l10n_notifications_settings_screen_wrist_delivery_needs_notification_access_so_2a14e784) + - "you. Nothing leaves this device. Your choices are saved now and apply " + - "automatically once access is granted.", - style = NoopType.footnote, - color = Palette.textSecondary, - ) - } - Row( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { - runCatching { - context.startActivity( - Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - ) - } +private fun CallsControlCard(master: Boolean, enabled: Boolean, phoneEnabled: Boolean, voipEnabled: Boolean, pattern: BuzzPattern, commandReady: Boolean, permissionDenied: Boolean, onEnabled: (Boolean) -> Unit, onPhone: (Boolean) -> Unit, onVoip: (Boolean) -> Unit, onPattern: (BuzzPattern) -> Unit, onTest: () -> Unit) { + SectionCard(Icons.Filled.Call, "Calls", "Phone calls deserve the most obvious wrist cue.") { + ToggleRow("Incoming calls", "Master switch for call haptics.", enabled, master, onEnabled) + if (enabled) { + DividerLine() + ToggleRow("Phone calls", "Native cellular calls. NOOP does not upload numbers or call logs.", phoneEnabled, master, onPhone) + if (permissionDenied) Text("Phone permission was denied. Turn it on in Android Settings to detect calls.", style = NoopType.footnote, color = Palette.statusCritical) + DividerLine() + ToggleRow("VoIP calls", "Best-effort detection for supported calling apps.", voipEnabled, master, onVoip) + DividerLine() + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text("Call pattern", style = NoopType.body, color = Palette.textPrimary) + Text("Immediate buzz + finite reminders while ringing.", style = NoopType.footnote, color = Palette.textTertiary) } - .padding(horizontal = 2.dp, vertical = 2.dp) - .semantics { contentDescription = uiString(R.string.l10n_notifications_settings_screen_open_notification_access_settings_93fcd1bf) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Icon( - Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - tint = Palette.accent, - modifier = Modifier.size(14.dp), - ) - Text(uiString(R.string.l10n_notifications_settings_screen_open_notification_access_658fd30f), style = NoopType.caption, color = Palette.accent) + PatternPicker(pattern, master, onPattern) + ActionPill("Test", Icons.Filled.GraphicEq, commandReady && master, onTest) + } } } } -// MARK: - Category card (rows of apps, dimmed/disabled when master is off) - @Composable -private fun CategoryCard( - category: NotifCategory, - apps: List, - masterEnabled: Boolean, - bonded: Boolean, - enabledState: SnapshotStateMap, - patternState: SnapshotStateMap, - onToggle: (NotifApp, Boolean) -> Unit, - onPattern: (NotifApp, BuzzPattern) -> Unit, - onTest: (NotifApp) -> Unit, -) { - val contentAlpha = if (masterEnabled) 1f else Palette.disabledOpacity - AlertSection(icon = category.icon, title = category.title) { - Column(modifier = Modifier.alphaIf(contentAlpha)) { - apps.forEachIndexed { idx, app -> - AppRow( - app = app, - enabled = enabledState[app.id] ?: false, - pattern = patternState[app.id] ?: app.category.defaultPattern, - interactive = masterEnabled, - bonded = bonded, - onToggle = { onToggle(app, it) }, - onPattern = { onPattern(app, it) }, - onTest = { onTest(app) }, - ) - if (idx < apps.size - 1) RowDivider() +private fun AppsControlCard(master: Boolean, query: String, onQueryChange: (String) -> Unit, enabledState: SnapshotStateMap, patternState: SnapshotStateMap, onToggle: (NotifApp, Boolean) -> Unit, onPattern: (NotifApp, BuzzPattern) -> Unit, onTest: (NotifApp) -> Unit) { + val filtered = remember(query) { + val normalized = query.trim().lowercase() + if (normalized.isEmpty()) notifCatalog else notifCatalog.filter { it.name.lowercase().contains(normalized) || it.id.lowercase().contains(normalized) } + } + SectionCard(Icons.Filled.NotificationsActive, "Apps", "Choose exactly which notifications deserve your attention.") { + OutlinedTextField(value = query, onValueChange = onQueryChange, modifier = Modifier.fillMaxWidth(), singleLine = true, leadingIcon = { Icon(Icons.Filled.Search, null) }, placeholder = { Text("Search apps") }, shape = RoundedCornerShape(12.dp)) + if (filtered.isEmpty()) { + Text("No supported app matches this search.", style = NoopType.footnote, color = Palette.textTertiary) + } else { + filtered.groupBy { it.category }.forEach { (category, apps) -> + Text(category.title, style = NoopType.caption, color = Palette.accent) + apps.forEachIndexed { index, app -> + AppAlertRow(app, enabledState[app.id] ?: false, patternState[app.id] ?: app.category.defaultPattern, master, { onToggle(app, it) }, { onPattern(app, it) }, { onTest(app) }) + if (index != apps.lastIndex) DividerLine() + } } } } } @Composable -private fun AppRow( - app: NotifApp, - enabled: Boolean, - pattern: BuzzPattern, - interactive: Boolean, - bonded: Boolean, - onToggle: (Boolean) -> Unit, - onPattern: (BuzzPattern) -> Unit, - onTest: () -> Unit, -) { +private fun AppAlertRow(app: NotifApp, enabled: Boolean, pattern: BuzzPattern, interactive: Boolean, onToggle: (Boolean) -> Unit, onPattern: (BuzzPattern) -> Unit, onTest: () -> Unit) { Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp) - // An enabled app reads as a selected row: a soft accentMuted wash behind it. - .clip(RoundedCornerShape(10.dp)) - .then(if (enabled) Modifier.background(Palette.accentMuted) else Modifier) - .padding(horizontal = 8.dp), + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(if (enabled) Palette.accentMuted.copy(alpha = 0.45f) else Palette.surfaceInset.copy(alpha = 0.45f)).padding(horizontal = 10.dp, vertical = 9.dp).alpha(if (interactive) 1f else Palette.disabledOpacity), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - // App glyph in a rounded inset tile (stand-in for the real macOS app icon). - Box( - modifier = Modifier - .size(34.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Palette.surfaceInset), - contentAlignment = Alignment.Center, - ) { - Icon(app.glyph, contentDescription = null, tint = Palette.textSecondary, modifier = Modifier.size(18.dp)) - } - - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Box(Modifier.size(34.dp).clip(RoundedCornerShape(9.dp)).background(Palette.surfaceRaised), Alignment.Center) { Icon(app.glyph, null, tint = Palette.textSecondary, modifier = Modifier.size(18.dp)) } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(app.name, style = NoopType.body, color = Palette.textPrimary) - Text( - if (enabled) "Buzzes your wrist" else "Off", - style = NoopType.footnote, - color = if (enabled) Palette.accent else Palette.textTertiary, - ) + Text(if (enabled) "Wrist alert enabled" else "Off", style = NoopType.footnote, color = if (enabled) Palette.accent else Palette.textTertiary) } - if (enabled) { - PatternMenu( - pattern = pattern, - enabled = interactive, - appName = app.name, - onSelect = onPattern, - ) - TestIconButton(enabled = interactive && bonded, appName = app.name, onClick = onTest) + PatternPicker(pattern, interactive, onPattern) + ActionPill("Test", Icons.Filled.GraphicEq, interactive, onTest) } - - NoopSwitch( - checked = enabled, - onChange = onToggle, - enabled = interactive, - label = uiString(R.string.l10n_notifications_settings_screen_app_name_wrist_alerts_dd3540fa, app.name), - ) + NoopSwitch(enabled, onToggle, interactive, "${app.name} wrist alert") } } -// MARK: - Pattern menu (DropdownMenu replacing the macOS Menu) - @Composable -private fun PatternMenu( - pattern: BuzzPattern, - enabled: Boolean, - appName: String, - onSelect: (BuzzPattern) -> Unit, -) { - var expanded by remember { mutableStateOf(false) } - val shape = RoundedCornerShape(50) - Box { - Row( - modifier = Modifier - .clip(shape) - .background(Palette.surfaceInset) - .border(1.dp, Palette.hairline, shape) - .clickable(enabled = enabled) { expanded = true } - .padding(horizontal = 10.dp, vertical = 5.dp) - .semantics { contentDescription = uiString(R.string.l10n_notifications_settings_screen_buzz_pattern_for_appname_905a31bd, appName) }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Icon( - Icons.Filled.GraphicEq, - contentDescription = null, - tint = Palette.textSecondary, - modifier = Modifier.size(12.dp), - ) - Text(pattern.label, style = NoopType.caption, color = Palette.textSecondary) - } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.background(Palette.surfaceOverlay), - ) { - BuzzPattern.entries.forEach { p -> - DropdownMenuItem( - text = { - Text( - p.label, - style = NoopType.body, - color = if (p == pattern) Palette.accent else Palette.textPrimary, - ) - }, - onClick = { - onSelect(p) - expanded = false - }, - ) - } - } +private fun NotificationBehaviourCard(master: Boolean, wornOnly: Boolean, quiet: Boolean, alarmTimer: Boolean, allOther: Boolean, onWornOnly: (Boolean) -> Unit, onQuiet: (Boolean) -> Unit, onAlarm: (Boolean) -> Unit, onAllOther: (Boolean) -> Unit) { + SectionCard(Icons.Filled.Tune, "Behaviour", "Rules that keep wrist alerts useful instead of noisy.") { + ToggleRow("Only when worn", "Don't buzz an unattended strap.", wornOnly, master, onWornOnly) + DividerLine() + ToggleRow("Quiet hours", "Mute all wrist alerts during your saved quiet window.", quiet, master, onQuiet) + DividerLine() + ToggleRow("Phone alarms & timers", "Buzz when another clock app posts an alarm notification.", alarmTimer, master, onAlarm) + DividerLine() + ToggleRow("Other apps", "Allow notifications from apps not listed above. This can be noisy.", allOther, master, onAllOther) } } -// MARK: - Test buttons +@Composable +private fun AccessCard(context: Context, notificationAccess: Boolean) { + val phonePermission = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == PackageManager.PERMISSION_GRANTED + SectionCard(Icons.Filled.Settings, "Permissions & privacy", "NOOP needs Android notification access to see app events.") { + StatusRow(Icons.Filled.NotificationsActive, "Notification access", if (notificationAccess) "Enabled — app events can be received locally." else "Required for app and VoIP notification detection.", notificationAccess) + StatusRow(Icons.Filled.Phone, "Phone state permission", if (phonePermission) "Enabled — native call state can be detected." else "Required only for native cellular call detection.", phonePermission) + DividerLine() + Text("Notification contents stay on this phone. NOOP uses the posting app and event state to decide whether to buzz your WHOOP.", style = NoopType.footnote, color = Palette.textSecondary) + ActionPill("Open Notification Access", Icons.Filled.OpenInNew, true) { runCatching { context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } } + } +} @Composable -private fun TestIconButton(enabled: Boolean, appName: String, onClick: () -> Unit) { - val shape = RoundedCornerShape(8.dp) - val tint = if (enabled) Palette.accent else Palette.textTertiary - Box( - modifier = Modifier - .size(28.dp) - .clip(shape) - .background(Palette.accent.copy(alpha = if (enabled) 0.12f else 0.04f)) - .border(1.dp, tint.copy(alpha = 0.30f), shape) - .clickable(enabled = enabled, onClick = onClick) - .semantics { contentDescription = uiString(R.string.l10n_notifications_settings_screen_test_appname_buzz_dbae5be3, appName) }, - contentAlignment = Alignment.Center, - ) { - Icon(Icons.Filled.PlayArrow, contentDescription = null, tint = tint, modifier = Modifier.size(15.dp)) +private fun DiagnosticsCard(context: Context, enabled: Boolean, connected: Boolean, encryptedBond: Boolean, worn: Boolean, notificationAccess: Boolean, callsEnabled: Boolean) { + val wearGate = NotifPrefs.getBool(context, NotifPrefs.WORN, true) + val ready = enabled && connected && encryptedBond && notificationAccess && (!wearGate || worn) + SectionCard(Icons.Filled.GraphicEq, "Delivery diagnostics", "A quick explanation of why an alert can or cannot reach your wrist.") { + StatusRow(Icons.Filled.Watch, "WHOOP connection", if (connected) "Connected" else "Disconnected — connect your WHOOP first.", connected) + StatusRow(Icons.Filled.CheckCircle, "Secure command link", if (encryptedBond) "Ready for haptic commands" else "Not ready — haptics will be held.", encryptedBond) + StatusRow(Icons.Filled.NotificationsActive, "Notification listener", if (notificationAccess) "Ready for app events" else "Disabled — app alerts and VoIP detection are unavailable.", notificationAccess) + StatusRow(Icons.Filled.Call, "Call alerts", if (callsEnabled) "Enabled" else "Disabled", callsEnabled) + DividerLine() + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Overall readiness", style = NoopType.body, color = Palette.textPrimary) + Text(if (ready) "Everything required for local wrist delivery is ready." else "One or more prerequisites need attention.", style = NoopType.footnote, color = Palette.textTertiary) + } + StatusChip(Icons.Filled.CheckCircle, if (ready) "READY" else "CHECK", ready) + } } } @Composable -private fun PillButton(label: String, icon: ImageVector, enabled: Boolean, onClick: () -> Unit) { - val shape = RoundedCornerShape(50) - val tint = if (enabled) Palette.accent else Palette.textTertiary +private fun StatusRow(icon: ImageVector, title: String, detail: String, positive: Boolean) { Row( - modifier = Modifier - .clip(shape) - .background(Palette.accent.copy(alpha = if (enabled) 0.12f else 0.04f)) - .border(1.dp, tint.copy(alpha = 0.30f), shape) - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 6.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(14.dp)) - Text(label, style = NoopType.caption, color = tint) + Icon(icon, null, tint = if (positive) Palette.accent else Palette.textTertiary, modifier = Modifier.size(18.dp)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = NoopType.body, color = Palette.textPrimary) + Text(detail, style = NoopType.footnote, color = Palette.textTertiary) + } + StatusChip(Icons.Filled.CheckCircle, if (positive) "OK" else "WAIT", positive) } } -// MARK: - Time chip (TimePickerDialog → HH:mm). Reused by the Automations smart-alarm time too. +private fun notificationAccessGranted(context: Context): Boolean { + val enabled = Settings.Secure.getString(context.contentResolver, "enabled_notification_listeners") ?: return false + return enabled.split(":").any { component -> component.startsWith(context.packageName) } +} -@OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun TimeChip( - minutes: Int, - accessibilityLabel: String, - onPicked: (Int) -> Unit, -) { - var showPicker by remember { mutableStateOf(false) } - val shape = RoundedCornerShape(50) - val hour = minutes / 60 - val minute = minutes % 60 - Text( - text = uiString(R.string.l10n_notifications_settings_screen_02d_02d_ce23a78c, hour, minute), - style = NoopType.number(15f), - color = Palette.accent, - modifier = Modifier - .clip(shape) - .background(Palette.surfaceInset) - .border(1.dp, Palette.hairline, shape) - .clickable { showPicker = true } - .padding(horizontal = 12.dp, vertical = 6.dp) - .semantics { contentDescription = accessibilityLabel }, - ) - - if (showPicker) { - // Material3 1.2.x has TimePicker + rememberTimePickerState but not a packaged - // TimePickerDialog, so we wrap the picker in a plain Dialog ourselves. - val state = rememberTimePickerState( - initialHour = hour, - initialMinute = minute, - is24Hour = true, - ) - Dialog(onDismissRequest = { showPicker = false }) { - Column( - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(Palette.surfaceOverlay) - .border(1.dp, Palette.hairline, RoundedCornerShape(20.dp)) - .padding(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text(accessibilityLabel, style = NoopType.headline, color = Palette.textPrimary) - TimePicker( - state = state, - colors = TimePickerDefaults.colors( - clockDialColor = Palette.surfaceInset, - clockDialSelectedContentColor = Palette.surfaceBase, - clockDialUnselectedContentColor = Palette.textPrimary, - selectorColor = Palette.accent, - periodSelectorBorderColor = Palette.hairline, - timeSelectorSelectedContainerColor = Palette.accentMuted, - timeSelectorUnselectedContainerColor = Palette.surfaceInset, - timeSelectorSelectedContentColor = Palette.accent, - timeSelectorUnselectedContentColor = Palette.textPrimary, - ), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), - ) { - Text( - uiString(R.string.l10n_notifications_settings_screen_cancel_77dfd213), - style = NoopType.body, - color = Palette.textSecondary, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { showPicker = false } - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - Text( - uiString(R.string.l10n_notifications_settings_screen_set_448ab73b), - style = NoopType.body, - color = Palette.accent, - modifier = Modifier - .clip(RoundedCornerShape(50)) - .clickable { - onPicked(state.hour * 60 + state.minute) - showPicker = false - } - .padding(horizontal = 16.dp, vertical = 8.dp), - ) - } +private fun SectionCard(icon: ImageVector, title: String, subtitle: String, content: @Composable () -> Unit) { + NoopCard(padding = 18.dp, tint = Palette.hairline) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box(Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(Palette.accentMuted), Alignment.Center) { Icon(icon, null, tint = Palette.accent, modifier = Modifier.size(18.dp)) } + Column { Text(title, style = NoopType.title2, color = Palette.textPrimary); Text(subtitle, style = NoopType.footnote, color = Palette.textSecondary) } } + content() } } } -// MARK: - Section card (icon + title header, optional blurb, content) - @Composable -private fun AlertSection( - icon: ImageVector, - title: String, - blurb: String? = null, - overline: String = "Alerts", - content: @Composable () -> Unit, -) { - NoopCard(padding = 20.dp, tint = Palette.accent) { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Overline(overline) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Icon(icon, contentDescription = null, tint = Palette.accent, modifier = Modifier.size(18.dp)) - Text(title, style = NoopType.title2, color = Palette.textPrimary) - } - } - if (blurb != null) { - Text(blurb, style = NoopType.subhead, color = Palette.textSecondary) - } - content() - } +private fun ToggleRow(label: String, help: String, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { Text(label, style = NoopType.body, color = Palette.textPrimary); Text(help, style = NoopType.footnote, color = Palette.textTertiary) } + Spacer(Modifier.width(12.dp)) + NoopSwitch(checked, onChange, enabled, label) } } -// MARK: - Label + help + switch row (mirrors FormToggleRow) - @Composable -private fun FormToggleRow( - label: String, - help: String, - checked: Boolean, - enabled: Boolean = true, - onChange: (Boolean) -> Unit, -) { +private fun StatusChip(icon: ImageVector, text: String, positive: Boolean) { + val tint = if (positive) Palette.accent else Palette.textTertiary Row( - modifier = Modifier - .fillMaxWidth() - .height(48.dp), + modifier = Modifier.clip(RoundedCornerShape(50)).background(Palette.surfaceInset).border(1.dp, tint.copy(alpha = 0.22f), RoundedCornerShape(50)).padding(horizontal = 10.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(label, style = NoopType.body, color = Palette.textPrimary) - Text(help, style = NoopType.footnote, color = Palette.textTertiary) - } - Spacer(Modifier.width(16.dp)) - NoopSwitch(checked = checked, onChange = onChange, enabled = enabled, label = label) + Icon(icon, null, tint = tint, modifier = Modifier.size(13.dp)) + Text(text, style = NoopType.caption, color = tint) } } -// MARK: - Shared bits - @Composable -private fun NoopSwitch( - checked: Boolean, - onChange: (Boolean) -> Unit, - enabled: Boolean = true, - label: String, -) { - Switch( - checked = checked, - onCheckedChange = onChange, - enabled = enabled, - colors = SwitchDefaults.colors( - checkedThumbColor = Palette.surfaceBase, - checkedTrackColor = Palette.accent, - uncheckedThumbColor = Palette.textSecondary, - uncheckedTrackColor = Palette.surfaceInset, - uncheckedBorderColor = Palette.hairline, - ), - modifier = Modifier.semantics { contentDescription = label }, - ) +private fun ActionPill(label: String, icon: ImageVector, enabled: Boolean, onClick: () -> Unit) { + val tint = if (enabled) Palette.accent else Palette.textTertiary + Row( + modifier = Modifier.clip(RoundedCornerShape(50)).background(tint.copy(alpha = if (enabled) 0.12f else 0.04f)).border(1.dp, tint.copy(alpha = 0.25f), RoundedCornerShape(50)).clickable(enabled = enabled, onClick = onClick).padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Icon(icon, null, tint = tint, modifier = Modifier.size(13.dp)) + Text(label, style = NoopType.caption, color = tint) + } } @Composable -private fun RowDivider() { - Box( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .padding(vertical = 4.dp) - .background(Palette.hairline), - ) +private fun PatternPicker(pattern: BuzzPattern, enabled: Boolean, onSelect: (BuzzPattern) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + ActionPill(pattern.label, Icons.Filled.GraphicEq, enabled) { expanded = true } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + BuzzPattern.entries.forEach { option -> + DropdownMenuItem(text = { Text(if (option == pattern) "✓ ${option.label}" else option.label, color = Palette.textPrimary) }, onClick = { onSelect(option); expanded = false }) + } + } + } } -/** Apply a uniform alpha to a subtree (dims disabled category content). */ -private fun Modifier.alphaIf(value: Float): Modifier = this.alpha(value) +@Composable +private fun NoopSwitch(checked: Boolean, onChange: (Boolean) -> Unit, enabled: Boolean, label: String) { + Switch(checked = checked, onCheckedChange = onChange, enabled = enabled, colors = SwitchDefaults.colors(checkedThumbColor = Palette.surfaceBase, checkedTrackColor = Palette.accent, uncheckedThumbColor = Palette.textSecondary, uncheckedTrackColor = Palette.surfaceInset, uncheckedBorderColor = Palette.hairline)) +} diff --git a/android/app/src/main/java/com/noop/ui/TimeChip.kt b/android/app/src/main/java/com/noop/ui/TimeChip.kt new file mode 100644 index 0000000000..0311df9bc2 --- /dev/null +++ b/android/app/src/main/java/com/noop/ui/TimeChip.kt @@ -0,0 +1,94 @@ +package com.noop.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDefaults +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog + +/** Shared 24-hour time selector used by Automations and other settings screens. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TimeChip( + minutes: Int, + accessibilityLabel: String, + onPicked: (Int) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + val hour = (minutes / 60).coerceIn(0, 23) + val minute = (minutes % 60).coerceIn(0, 59) + + Text( + text = "%02d:%02d".format(hour, minute), + style = NoopType.number(15f), + color = Palette.accent, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(Palette.surfaceInset) + .border(1.dp, Palette.hairline, RoundedCornerShape(50)) + .clickable { showPicker = true } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + + if (showPicker) { + val state = rememberTimePickerState(initialHour = hour, initialMinute = minute, is24Hour = true) + Dialog(onDismissRequest = { showPicker = false }) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(20.dp)) + .background(Palette.surfaceOverlay) + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(accessibilityLabel, style = NoopType.headline, color = Palette.textPrimary) + TimePicker( + state = state, + colors = TimePickerDefaults.colors( + clockDialColor = Palette.surfaceInset, + clockDialSelectedContentColor = Palette.surfaceBase, + clockDialUnselectedContentColor = Palette.textPrimary, + selectorColor = Palette.accent, + periodSelectorBorderColor = Palette.hairline, + timeSelectorSelectedContainerColor = Palette.accentMuted, + timeSelectorUnselectedContainerColor = Palette.surfaceInset, + timeSelectorSelectedContentColor = Palette.accent, + timeSelectorUnselectedContentColor = Palette.textPrimary, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + ) { + Text("Cancel", color = Palette.textSecondary, modifier = Modifier.clickable { showPicker = false }.padding(10.dp)) + Text( + "Set", + color = Palette.accent, + modifier = Modifier.clickable { + onPicked(state.hour * 60 + state.minute) + showPicker = false + }.padding(10.dp), + ) + } + } + } + } +} diff --git a/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt b/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt index 8f23ec839d..1bbda0da36 100644 --- a/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt +++ b/android/app/src/test/java/com/noop/notif/CallAlertPolicyTest.kt @@ -6,7 +6,14 @@ import org.junit.Assert.assertTrue import org.junit.Test class CallAlertPolicyTest { - private val policy = CallAlertPolicy(repeatIntervalMs = 8_000L, maxBuzzes = 4) + private val policy = CallAlertPolicy(repeatIntervalMs = 6_000L, maxBuzzes = 6) + + @Test + fun defaultsAreSuitableForIncomingCalls() { + val defaults = CallAlertPolicy() + assertEquals(6_000L, defaults.repeatIntervalMs) + assertEquals(6, defaults.maxBuzzes) + } @Test fun buzzesImmediatelyForActiveCall() { @@ -15,14 +22,24 @@ class CallAlertPolicyTest { @Test fun throttlesUntilRepeatInterval() { - assertFalse(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 8_999L)) - assertTrue(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 9_000L)) + assertFalse(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 6_999L)) + assertTrue(policy.shouldBuzz(active = true, buzzCount = 1, lastBuzzAtMs = 1_000L, nowMs = 7_000L)) } @Test fun stopsAfterMaxBuzzesOrInactiveCall() { - assertFalse(policy.shouldBuzz(active = true, buzzCount = 4, lastBuzzAtMs = 1_000L, nowMs = 20_000L)) + assertFalse(policy.shouldBuzz(active = true, buzzCount = 6, lastBuzzAtMs = 1_000L, nowMs = 20_000L)) assertFalse(policy.shouldBuzz(active = false, buzzCount = 0, lastBuzzAtMs = null, nowMs = 1_000L)) - assertEquals(null, policy.nextDelayMs(buzzCount = 4)) + assertEquals(null, policy.nextDelayMs(buzzCount = 6)) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsZeroRepeatInterval() { + CallAlertPolicy(repeatIntervalMs = 0L) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsZeroBuzzLimit() { + CallAlertPolicy(maxBuzzes = 0) } } diff --git a/docs/NOOP_NEXT.md b/docs/NOOP_NEXT.md new file mode 100644 index 0000000000..fc31d52f96 --- /dev/null +++ b/docs/NOOP_NEXT.md @@ -0,0 +1,51 @@ +# NOOP Next — call haptics and notification UX + +NOOP should feel like a focused wearable companion: phone events are detected locally, evaluated by a small policy engine, and delivered to the WHOOP through the existing BLE transport. + +## Call-alert behaviour + +- Immediate haptic when an enabled native phone or VoIP call begins. +- Repeat every 6 seconds while the call remains active. +- Maximum 6 haptic deliveries per call cycle. +- Duplicate ringing events are idempotent. +- A disconnected WHOOP does not consume a delivery slot; the alert retries after reconnection. +- A five-minute watchdog clears a leaked active-call token if Android misses an end/removal callback. +- Respect notification master, call master, source switches, quiet hours, and wear gating. +- GSM phone calls remain on the native phone-state path; app notifications are handled by NotificationListenerService. +- Haptic encoding remains inside `WhoopBleClient.buzz()`, allowing WHOOP 4.0 and WHOOP 5/MG transports to stay hardware-specific. + +## Current UI + +The Android notification screen already uses the vNext control-centre layout: + +1. Wrist-alert readiness and connection status. +2. Calls with independent Phone / VoIP controls. +3. Physical Test Buzz action. +4. App alerts grouped by category with per-app patterns. +5. Behaviour rules: wear gating, quiet hours, alarms/timers, other apps. +6. Permissions and privacy diagnostics. + +The important path is deliberately visible: **phone event → NOOP detects → policy allows → encrypted WHOOP link → haptic delivered**. + +## WHOOP 5/MG research + +Current reverse-engineering work indicates that WHOOP 4.0 and WHOOP 5/MG do not use the same haptic opcode. WHOOP 4.0 uses `RUN_HAPTICS_PATTERN` (79), while the current NOOP research notes identify the 5/MG "maverick" haptic command as `0x13`. Keep that mapping inside the BLE client and never duplicate protocol bytes in the notification layer. + +The Android notification code gates delivery on `LiveState.encryptedBond`, not merely the looser `bonded` signal. This prevents a 5/MG live-HR-only connection from being treated as a command-capable link. + +## Reliability acceptance tests + +1. Incoming GSM call produces an immediate WHOOP buzz. +2. A ringing call produces reminders at the finite cadence. +3. Ending the call stops reminders immediately. +4. Duplicate RINGING events never create a haptic storm. +5. Disconnect/reconnect during ringing does not lose a delivery slot. +6. Quiet hours suppress call haptics. +7. Wear gating suppresses haptics when the strap is not worn. +8. A missed stop callback self-heals after the watchdog. +9. Simultaneous GSM and VoIP sources share one scheduler. +10. WHOOP 4.0 and WHOOP 5/MG continue using their existing hardware-specific BLE haptic implementations. + +## Privacy + +Notification text, phone numbers, and call logs remain on-device. The call-alert path requires no cloud relay.