Skip to content
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,3 @@ protocol alongside us — this project is built on it.
</p>

![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)
61 changes: 41 additions & 20 deletions android/app/src/main/java/com/noop/notif/CallAlertController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand All @@ -57,32 +60,45 @@ 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()
}

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
Expand Down Expand Up @@ -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
}
}
21 changes: 14 additions & 7 deletions android/app/src/main/java/com/noop/notif/CallAlertPolicy.kt
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down
24 changes: 15 additions & 9 deletions android/app/src/main/java/com/noop/notif/VoipCallClassifier.kt
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -20,24 +23,26 @@ internal object VoipCallClassifier {
"com.google.android.apps.tachyon",
"com.google.android.apps.meetings",
"com.facebook.orca",
"com.discord",
"com.instagram.android",
)

data class Metadata(
val category: String?,
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 =
Expand All @@ -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,
)
}
109 changes: 109 additions & 0 deletions android/app/src/main/java/com/noop/ui/NotificationSettingsModel.kt
Original file line number Diff line number Diff line change
@@ -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<NotifApp> = 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
}
}
Loading
Loading