Skip to content
Open
12 changes: 12 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@
<package android:name="com.vivo.permissionmanager" />
<package android:name="com.huawei.systemmanager" />
<package android:name="com.meizu.safe" />

<!-- Wrist-alert app picker: visibility of every launchable app, so InstalledNotifierApps
can enumerate "apps that can notify you" (filtered by POST_NOTIFICATIONS declaration)
without QUERY_ALL_PACKAGES (Play-restricted, and NOOP ships sideloaded — keep the
narrow intent-shaped query). Apps that post notifications without declaring the
permission (pre-API-33 targets) are discovered through the notification listener
itself: the Notification Access grant carries their visibility.
See com/noop/notif/NotifierAppDiscovery.kt. -->
<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent>
</queries>

<application
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/java/com/noop/NoopApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ class NoopApplication : Application() {
// #1008: pin the pre-change Overnight-only default for existing installs before anything
// reads it. Idempotent; a no-op on fresh installs and on every launch after the first.
com.noop.ui.NoopPrefs.migrateContinuousHrvOvernightDefault(this)
// #2707: pin the pre-picker buzz patterns for apps opted in under the old category cards
// (their defaults were never persisted; the picker defaults everything to Double).
// Idempotent flag-guarded walk, no-op on fresh installs and after the first launch.
com.noop.ui.NotifPrefs.seedLegacyCategoryPatternDefaults(this)
// #2185: a stress widget placed by an older version fired its `onEnabled` long before the
// scheduler existed, so the receiver hook alone would never reach it. Enqueued with KEEP, so
// this is a no-op once a schedule exists, and the worker retires itself when no widget is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class NoopNotificationListener : NotificationListenerService() {
val ctx = applicationContext
val n = sbn.notification ?: return

// Discovery for the per-app picker: every package that posts becomes selectable in the
// Notifications screen's "Other apps" card. Package NAME only — content is never read.
// The notification-access grant that delivers this callback also grants visibility of the
// posting package, which is what lets us list apps a PackageManager query cannot see
// (pre-API-33 targets that never declare POST_NOTIFICATIONS). See NotifierAppDiscovery.
NotifierAppDiscovery.record(ctx, sbn.packageName)

if (VoipCallClassifier.isKnownVoipPackage(sbn.packageName)) {
val metadata = VoipCallClassifier.metadataOf(n, isOngoing = sbn.isOngoing)
if (VoipCallClassifier.isIncomingCallNotification(sbn.packageName, metadata)) {
Expand Down Expand Up @@ -63,8 +70,13 @@ class NoopNotificationListener : NotificationListenerService() {
// Buzz at most ONCE per notification key. `add()` is the LAST term, so a key is recorded only
// when we actually buzz (deliverable) — a re-posting/updating notification can't storm, and a
// genuine re-ring gets a fresh key (cleared in onNotificationRemoved) so it buzzes again.
// No progress buzzes here either: a running timer posts as a CATEGORY_ALARM row with a
// countdown bar, so the gate silences timer STARTS; the alarm going off is a fresh post
// without progress, so rings keep their triple buzz (ongoing stays allowed on this path —
// that is the whole point of #1115 — so the persistent gate is deliberately NOT applied).
if (ble != null && !NotifPrefs.inQuietHours(ctx) &&
(!NotifPrefs.getBool(ctx, NotifPrefs.WORN, true) || ble.state.value.worn) &&
!WristAlertFilters.isProgressNotification(n) &&
buzzedAlarmKeys.add(sbn.key)) {
ble.buzz(3) // a strong triple cue; send() no-ops if the strap isn't connected
}
Expand All @@ -82,6 +94,10 @@ class NoopNotificationListener : NotificationListenerService() {
if (sbn.isOngoing) return
if ((n.flags and Notification.FLAG_GROUP_SUMMARY) != 0) return
if ((n.flags and Notification.FLAG_FOREGROUND_SERVICE) != 0) return
// Buzz specificity: progress notifications (downloads, uploads, timer countdown bars) and
// persistent non-dismissable rows are app state readouts, not alerts — never buzz for them.
if (WristAlertFilters.isProgressNotification(n)) return
if (WristAlertFilters.isPersistentNotification(n)) return

if (NotifPrefs.inQuietHours(ctx)) return

Expand Down
189 changes: 189 additions & 0 deletions android/app/src/main/java/com/noop/notif/NotifierAppDiscovery.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package com.noop.notif

import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.provider.Settings

/**
* Discovery of apps that can notify this phone, backing the per-app wrist-alert picker.
*
* Two complementary sources, because Android's package-visibility filtering (API 30+) never gives a
* single query the full picture:
*
* 1. [InstalledNotifierApps] asks the [PackageManager] for apps that declare POST_NOTIFICATIONS.
* The manifest's `<queries><intent MAIN/LAUNCHER/></queries>` entry grants visibility to every
* launchable app; the filter keeps the ones declaring the notification permission. No
* QUERY_ALL_PACKAGES, so the app stays Play-policy-clean.
* 2. [NotifierAppDiscovery] persists every package the notification listener actually sees post.
* This catches apps targeting below API 33, which post notifications WITHOUT declaring
* POST_NOTIFICATIONS and are therefore invisible to query (1) — the same notification-access
* grant that delivers [NoopNotificationListener.onNotificationPosted] also makes those packages
* visible here.
*
* The Notifications screen merges both. Everything stays on-device: we record package NAMES only —
* never notification content, titles, senders, or text.
*/
object NotifierAppDiscovery {

/** Same prefs file the wrist-alert settings use (see NotifPrefs in NotificationsSettingsScreen). */
private const val FILE = "noop_notif_prefs"
private const val KEY_DISCOVERED = "notif.discoveredPackages"

/**
* Remember that [packageName] posted a notification. Called from the listener on every post.
* Cheap no-op for a known package — the write only happens the FIRST time a package is seen,
* so a chatty app cannot hammer SharedPreferences.
*/
fun record(ctx: Context, packageName: String) {
val prefs = ctx.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
val known = prefs.getStringSet(KEY_DISCOVERED, emptySet()) ?: emptySet()
if (packageName in known) return
prefs.edit().putStringSet(KEY_DISCOVERED, known + packageName).apply()
}

/** Every package the listener has ever seen post a notification on this device. */
fun discoveredPackages(ctx: Context): Set<String> =
ctx.applicationContext
.getSharedPreferences(FILE, Context.MODE_PRIVATE)
.getStringSet(KEY_DISCOVERED, emptySet())
?: emptySet()

/**
* Resolve one package to its picker row (label included), or null when it is not installed
* (or disabled). The App Notifications page uses this to show the opted-in apps the moment it
* opens, ahead of the full scan. Read OFF the main thread — getApplicationInfo crosses IPC.
*/
fun appFor(ctx: Context, packageName: String): NotifierApp? {
val pm = ctx.packageManager
val info = runCatching { pm.getApplicationInfo(packageName, 0) }.getOrNull() ?: return null
if (!info.enabled) return null
return NotifierApp(packageName, labelOf(pm, info))
}

/**
* Is Notification Access granted to THIS app? The grant is per-app: a fresh install does not
* inherit any other app's grant, and without it the listener service is never
* bound — no wrist alert can arrive no matter what the picker has toggled. The settings screen
* shows this so a silent dead link reads as what it is.
*/
fun notificationAccessGranted(ctx: Context): Boolean {
// The grant list is a ':'-separated flat of enabled listener components, readable on every
// API level NOOP supports (the "enabled_notification_listeners" Secure setting, API 22+).
// The API 29 NotificationManager accessor reads the same list; the flat covers minSdk 26
// through 35 in one path.
val flat = Settings.Secure.getString(ctx.contentResolver, "enabled_notification_listeners")
?: return false
val selfComponent = android.content.ComponentName(ctx, NoopNotificationListener::class.java)
.flattenToString()
return flat.split(":").any { it == selfComponent }
}

/**
* The picker list: installed apps that declare the notification permission, plus every
* discovered poster that is installed — sorted by label. Discovered apps that were
* uninstalled are dropped (their per-app prefs stay, so a reinstall keeps the user's choice).
* Read OFF the main thread: [InstalledNotifierApps.list] walks every visible package.
*
* The two paths filter system packages differently, on purpose. The enumeration path drops
* FLAG_SYSTEM so OEM agents and bundled services that never notify the user do not flood the
* list. The discovered path does NOT: a preinstalled app that has actually posted a
* notification (Gmail, Samsung Messages, a carrier app) is one the user demonstrably uses, and
* hiding it would leave a real notifier unpickable. Posting is the evidence; the flag is not.
*/
fun load(ctx: Context): List<NotifierApp> {
val installed = InstalledNotifierApps.list(ctx)
val known = installed.map { it.packageName }.toSet()
val pm = ctx.packageManager
val discovered = discoveredPackages(ctx)
.filter { it !in known && it != ctx.packageName }
.mapNotNull { pkg ->
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() ?: return@mapNotNull null
if (!info.enabled) return@mapNotNull null
// No FLAG_SYSTEM gate here, unlike the enumeration path: this package has actually
// posted a notification, which is the signal the feature is built on. Preinstalled
// mail/messaging apps carry FLAG_SYSTEM on most OEM builds and must stay pickable.
NotifierApp(pkg, labelOf(pm, info))
}
return (installed + discovered).sortedBy { it.label.lowercase() }
}
}

/** One app the picker can offer. `packageName` is the NotifPrefs persistence key (same key the
* notification listener gates on), `label` is the user-visible app name. */
data class NotifierApp(
val packageName: String,
val label: String,
)

/**
* Enumerates installed apps that can post notifications.
*
* The pure filter ([filterNotifierCandidates]) is separated from the [PackageManager] plumbing so
* the selection rule is unit-testable without a device (see NotifierAppDiscoveryTest).
*/
object InstalledNotifierApps {

/** Permission an app must declare to be offered: the API 33 notification-posting permission. */
const val POST_NOTIFICATIONS = "android.permission.POST_NOTIFICATIONS"

/**
* Installed apps that can notify, sorted by label. Excludes NOOP itself (our own notifications
* are governed by the Daily-reports card, not the wrist-alert picker).
*/
fun list(ctx: Context): List<NotifierApp> {
val pm = ctx.packageManager
val infos = runCatching { pm.getInstalledPackages(PackageManager.GET_PERMISSIONS) }
.getOrDefault(emptyList())
val candidates = infos.map { info ->
NotifierCandidate(
packageName = info.packageName,
requestsPostNotifications = info.requestedPermissions
?.contains(POST_NOTIFICATIONS) == true,
enabled = info.applicationInfo?.enabled == true,
systemApp = (info.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0,
)
}
return filterNotifierCandidates(candidates, selfPackage = ctx.packageName)
.map { candidate ->
// getApplicationLabel can throw on some system packages; the package name is an
// honest fallback — the picker keys on it, the label is only for reading.
val label = runCatching {
pm.getApplicationLabel(
pm.getApplicationInfo(candidate.packageName, 0),
).toString()
}.getOrDefault(candidate.packageName)
NotifierApp(candidate.packageName, label)
}
.sortedBy { it.label.lowercase() }
}

/**
* Pure selection rule: keep apps that DECLARE the notification-posting permission, are
* ENABLED, and are USER-INSTALLED (no FLAG_SYSTEM — OEM agents and bundled services are not
* apps the user installed); drop NOOP's own package. Declaring POST_NOTIFICATIONS is the signal "
* notify you" — apps that never declare it and never posted are not offered; apps that post
* without declaring (pre-API-33 targets) arrive through [NotifierAppDiscovery] instead.
*/
internal fun filterNotifierCandidates(
candidates: List<NotifierCandidate>,
selfPackage: String,
): List<NotifierCandidate> =
candidates.filter {
it.requestsPostNotifications && it.enabled && !it.systemApp && it.packageName != selfPackage
}
}

/** Plain-data view of one installed package, for the pure filter. */
internal data class NotifierCandidate(
val packageName: String,
val requestsPostNotifications: Boolean,
val enabled: Boolean,
val systemApp: Boolean = false,
)

/** User-visible app name. getApplicationLabel falls back to the package name when no label
* resolves — an honest fallback: the picker keys on the package, the label is only for reading. */
private fun labelOf(pm: PackageManager, info: ApplicationInfo): String =
runCatching { pm.getApplicationLabel(info).toString() }.getOrDefault(info.packageName)
62 changes: 62 additions & 0 deletions android/app/src/main/java/com/noop/notif/WristAlertFilters.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.noop.notif

import android.app.Notification

/**
* Noise filters for wrist-alert buzz specificity: progress notifications and persistent
* non-dismissable rows are app state readouts (download bars, uploads, timer countdowns, always-on
* launcher/service rows), not user-facing alerts — the listener must never buzz the strap for them.
* Mirrors [VoipCallClassifier]: [of] extracts framework state from the posted [Notification] and the
* policy is a pure function of that snapshot, so the rule is testable without a device.
*/
internal object WristAlertFilters {

// Notification.setProgress() stores its state in the notification extras under these framework
// keys (there is no public SDK constant for them — they are the documented extras keys).
const val EXTRA_PROGRESS = "android.progress"
const val EXTRA_PROGRESS_MAX = "android.progressMax"
const val EXTRA_PROGRESS_INDETERMINATE = "android.progressIndeterminate"

// A ProgressStyle notification declares this template in extras: the row inflates a ProgressBar.
const val PROGRESS_TEMPLATE = "android.widget.ProgressBar"

// KEY PRESENCE IS NOT THE SIGNAL — never gate on extras.containsKey(EXTRA_PROGRESS). The framework
// seeds android.progress=0, android.progressMax=0 and android.progressIndeterminate=false into the
// extras of ORDINARY text alerts (real device dumps show all three keys on plain notifications),
// so a containsKey gate silences every post. Only the VALUES, the ProgressStyle template, or
// NO_CLEAR distinguish a state readout from an alert. Pinned by WristAlertFiltersTest.

/** Framework snapshot of one posted notification, decoupled from android.jar for the tests. */
data class Snapshot(
val progress: Int,
val progressMax: Int,
val indeterminate: Boolean,
val template: String?,
val noClear: Boolean,
)

fun of(n: Notification): Snapshot {
val ex = n.extras
return Snapshot(
progress = ex?.getInt(EXTRA_PROGRESS, 0) ?: 0,
progressMax = ex?.getInt(EXTRA_PROGRESS_MAX, 0) ?: 0,
indeterminate = ex?.getBoolean(EXTRA_PROGRESS_INDETERMINATE, false) == true,
template = ex?.getString(Notification.EXTRA_TEMPLATE),
noClear = (n.flags and Notification.FLAG_NO_CLEAR) != 0,
)
}

/** True when the notification shows a progress bar — determinate, indeterminate, or styled. */
fun isProgressNotification(n: Notification): Boolean = of(n).isProgress()

/** True when the user cannot swipe the notification away ([Notification.FLAG_NO_CLEAR]). */
fun isPersistentNotification(n: Notification): Boolean = of(n).noClear
}

// The policy itself, as top-level extensions so tests (and callers) apply it directly to a Snapshot.
// A bar only exists when its VALUES say so: indeterminate spinners set the flag, determinate bars
// carry progress/progressMax above zero; the framework's 0/false defaults on plain alerts do not.
internal fun WristAlertFilters.Snapshot.isProgress(): Boolean =
indeterminate || progress > 0 || progressMax > 0 || template == WristAlertFilters.PROGRESS_TEMPLATE

internal fun WristAlertFilters.Snapshot.isPersistent(): Boolean = noClear
7 changes: 6 additions & 1 deletion android/app/src/main/java/com/noop/ui/AppRoot.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import androidx.compose.material.icons.automirrored.filled.CompareArrows
import androidx.compose.material.icons.automirrored.filled.TrendingUp
import androidx.compose.material.icons.filled.Air
import androidx.compose.material.icons.filled.Alarm
import androidx.compose.material.icons.filled.Apps
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.filled.Bolt
Expand Down Expand Up @@ -187,6 +188,9 @@ internal enum class Destination(
BackupSync("backup_sync", R.string.nav_backup_sync, Icons.Filled.CloudSync),
FusedRecord("fused_record", R.string.nav_fused_record, Icons.AutoMirrored.Filled.CompareArrows),
Notifications("notifications", R.string.nav_notifications, Icons.Filled.Notifications),
// The discovered "Other apps" wrist-alert picker: reached ONLY from the Notifications screen's
// button, so (like CoachSettings/StepsCalibration) it is deliberately absent from [drawerGroups].
OtherApps("other_apps", R.string.l10n_notifications_settings_screen_app_notifications_00e65fd6, Icons.Filled.Apps),
PowerSaving("power_saving", R.string.nav_power_saving, Icons.Filled.BatteryStd),
Settings("settings", R.string.nav_settings, Icons.Filled.Settings),
// Experimental and intentionally absent from More: reachable only through Settings > Advanced.
Expand Down Expand Up @@ -766,7 +770,8 @@ fun AppRoot(viewModel: AppViewModel = viewModel()) {
composable(Destination.DataSources.route) { DataSourcesScreen(viewModel) }
composable(Destination.NoopLimitations.route) { NoopLimitationsScreen() }
composable(Destination.BackupSync.route) { BackupSyncScreen() }
composable(Destination.Notifications.route) { NotificationsSettingsScreen(viewModel) }
composable(Destination.Notifications.route) { NotificationsSettingsScreen(viewModel, onOpenOtherApps = { nav.navigate(Destination.OtherApps.route) }) }
composable(Destination.OtherApps.route) { OtherAppsScreen(viewModel) }
composable(Destination.PowerSaving.route) { PowerSavingScreen(viewModel) }
composable(Destination.Settings.route) {
SettingsScreen(
Expand Down
Loading