Skip to content

Decide whether NOVA should alert outside the app, and by which route #321

Open

Description

@nofuturekid

Status: undecided. This is a product decision of the maintainer; nothing here is scheduled.

What and why

Decide whether NOVA should alert the user outside the app (a notification when the server reports something important), and if so by which route. This would reverse the 0.1.42 decision of no background polling.

User demand: "I would love being able to get push notifications for major issues." (r/unRAID, 2025-03-01, https://www.reddit.com/r/unRAID/comments/1j0ston/ , post with 173 points). "Push notifications, widgets, reliable background refresh are a few reasons for app vs mobile web." (2025-08-05, https://www.reddit.com/r/unRAID/comments/1mi1v85/). Counter-voice: "I'm put off by the idea of my stuff running through Discords servers." (6 points) and a note that Unraid already pushes through many services under Settings - Notifications (70 points), https://www.reddit.com/r/unRAID/comments/1wkolt8/ (2026-09-19).

Other apps (their own descriptions):

What NOVA has today

No alerts outside the app. Background polling was removed in 0.1.42 (HANDOFF.md). There is no WorkManager or NotificationCompat use in the code. Inside the app, the notification inbox works (mutations.graphql notification actions).

API side

Route (a) uses no NOVA API calls: Unraid's built-in notification agents send to a service the user runs. Route (b) would poll GetNotifications (queries.graphql:96) from a background job.

Options

  • (a) UnifiedPush through the user's own ntfy server, fed by Unraid's notification agents. NOVA only receives the message and opens the right screen. No NOVA relay, no Google services. Feasibility is unchecked (UnifiedPush distributor on the phone, ntfy topic setup, deep link into the right screen).
  • (b) An opt-in WorkManager check. Simple, but Android limits periodic work to at least 15 minutes, it needs the app to hold a key in the background, and it reverses the 0.1.42 decision.
  • (c) No alerts, and document how to use Unraid's own agents with the user's preferred notification app.

Recommendation of the market overview

Yes: UnifiedPush/ntfy first (a), after a short feasibility spike. This is the overview's opinion, not a decision.

Constraints from NOVA's principles

Official API only; no server plugin; no relay run by the project; no Google services (so no FCM); least-privilege keys.

Open questions

  • Is any alert feature wanted at all?
  • If (a): is a documented recipe enough, or should NOVA parse the ntfy message and deep-link?
  • If (b): what is the battery and privacy cost the maintainer accepts?

Acceptance criteria (after a decision)

  • The maintainer records the decision (option, or "no") in an ADR.
  • If (a) or (b): a spike result confirms feasibility before a feature issue is opened.

Size

L (if built); S for the spike.

Activity

  1. added
    enhancementNew feature or request
    questionFurther information is requested
    market-2026-11From the market overview of 2026-11-13
    on Nov 13, 2026
  2. nofuturekid commented on Nov 13, 2026

    @nofuturekid
    CollaboratorAuthor

    Decided by the maintainer (2026-11-13): yes, NOVA gets alerts outside the app, as an opt-in. Background checks run only while notifications are switched on; with the switch off (the default) the app does no background work at all, as today. The design (events, check interval, permission flow, privacy policy, routes without Google services and without a relay) follows in this issue before any code.

  3. nofuturekid commented on Nov 13, 2026

    @nofuturekid
    CollaboratorAuthor

    Design (2026-11-13)

    Decisions of 2026-11-13 (the maintainer, or the coordinator on the design's own recommendations by his standing rule):

    • Opt-in, default off. Background checks run only while the switch is on (maintainer).
    • Default level: "Alerts and warnings".
    • Home-screen widget (Home-screen widget with server health and array capacity #322): it refreshes when the app is opened and, only while alerts are switched on, with the same background job. It gets no background work of its own.

    Section 10 of the design ("Product questions") is therefore answered; its recommendations stand.

    Planned pull request split (section 7; order: PR 0 any time, PR 1 after #336 is on main, PR 2 on PR 1, PR 3 on PR 2, PR 4 together with PR 3):

    • PR 0: docs (ADR on opt-in background alert checks)
    • PR 1: rules and state (alert rules, state store, check addresses, settings keys)
    • PR 2: worker and scheduling (check worker, scheduler, server probe, notifier)
    • PR 3: settings UI (alerts section in Settings)
    • PR 4: documentation (privacy policy, README, site, store description)

    The full design follows in two comments (part 1 of 2: sections 0 to 6; part 2 of 2: sections 7 to 10 and sources).


    Alerts outside the app: implementation design (issue #321)

    Status: design for review, written 2026-11-13. Nothing here is built.
    Fixed by the maintainer (issue #321, comment 2026-11-13): opt-in, background work only while notifications are switched on, default off.
    Fixed by the project's principles: official Unraid API only, no server plugin, no relay or push service run by the project, no Google Play services, least-privilege (read-only) key, no telemetry.

    Every factual statement below carries its source. "Decision" marks an engineering choice made here, with its reason.


    0. Corrections to the starting assumptions

    Assumption (issue #321 body) What is true Evidence
    "Background polling was removed in 0.1.42" / alerts "reverse the 0.1.42 decision" NOVA never had background work. No commit on any ref added WorkManager, a Worker, a notification channel or POST_NOTIFICATIONS to app/. 0.1.42 fixed a bug: three foreground screens kept polling while the app was in the background. The rule that alerts must amend is ADR-0017 ("backgrounded app = zero polls, period"). git log --all -S POST_NOTIFICATIONS -- app/ → empty; CHANGELOG.md:193-195 "No more background polling — Settings → Server plugins, Network interfaces and the container Logs tab kept refreshing ... while the app was in the background"; docs/adr/0017-domain-split-queries-with-lifecycle-polling.md "All gating logic ANDs with appVisible — so backgrounded app = zero polls, period."
    Route (a) UnifiedPush through the user's ntfy, fed by Unraid's agents UnifiedPush requires the sender to encrypt each message per RFC 8291. Unraid's agents send plain HTTP posts. The route does not work as specified (section 2.2). unifiedpush.org/developers/spec/android (AND_3.1.0): "The message MUST be an encrypted content that follows" RFC 8291; unraid/webgui emhttp/plugins/dynamix/agents/ntfy.sh.xml:33-39 (plain curl -d "$MESSAGE")
    Issue #319's evaluation function is unpublished work It is now PR #336 (feat/needs-attention-card, head c79e7f1), open. timeout 120 gh pr list -R hellwand/nova --state open → {"number":336,"headRefOid":"c79e7f16..."}

    1. Summary of the design

    • One switch, Settings → Notifications → "Alerts from your servers", default off.
    • While it is on, one WorkManager periodic job (PeriodicWorkRequest, unique name nova-alert-check) runs every 30 minutes by default (15 min, 30 min, 1 h or 3 h selectable). It asks every saved server two existing, already shipped read-only queries (GetNotifications, GetArray) and posts Android notifications for:
      1. each new unread Unraid notification of importance ALERT or WARNING (per notification id, never twice), and
      2. two array conditions that Unraid itself never notifies about: "Array is not started" and "Array is degraded" (taken from PR feat: a needs-attention summary on the Overview #336's attentionItems(), posted when the condition appears, not while it lasts).
    • No foreground service, no expedited work, no exact alarms, no own boot receiver, no battery-exemption permission, no push.
    • With the switch off WorkManager is never initialised (on-demand initialisation), so there is no job, no alarm and no WorkManager database, exactly as today.
    • Unreachable servers stay silent; the result of the last check is shown in Settings.
    • The "instant" alternative is documented, not built: Unraid's own ntfy or Gotify agent to the user's own server and app.

    2. Mechanism

    2.1 Periodic check with WorkManager (build now)

    Platform facts (verified):

    Fact Evidence
    Periodic work runs at most every 15 minutes; the flex window is at least 5 minutes. androidx/androidx work/work-runtime/api/current.txt:379-380: MIN_PERIODIC_FLEX_MILLIS = 300000L, MIN_PERIODIC_INTERVAL_MILLIS = 900000L
    WorkManager merges RECEIVE_BOOT_COMPLETED, WAKE_LOCK, ACCESS_NETWORK_STATE, FOREGROUND_SERVICE and a BOOT_COMPLETED RescheduleReceiver that is disabled by default and enabled only while unfinished work exists. No own boot receiver is needed. work/work-runtime/src/main/AndroidManifest.xml:20-23 (permissions), :54-63 (android:enabled="false", BOOT_COMPLETED); UnfinishedWorkListener.kt:73-74 setReceiverEnabled(appContext, RescheduleReceiver::class.java, hasUnfinishedWork)
    A force stop cancels jobs until WorkManager is initialised again. ForceStopRunnable.java:70-71 "Alarms and Jobs get cancelled when an application is force-stopped."
    WorkManager's default initialiser sets a 10-year RTC_WAKEUP alarm at every app start (force-stop detection). ForceStopRunnable.java:87 TEN_YEARS, :423-425 alarmManager.setExact(RTC_WAKEUP, triggerAt, pendingIntent)
    Doze defers jobs to maintenance windows; standby buckets limit job time: Active 20 min/60 min, Working set 10 min/4 h, Frequent 10 min/12 h, Rare 10 min/24 h with network "Disabled", Restricted once per day for 10 min. Charging lifts the limits except for Restricted. developer.android.com/topic/performance/power/power-details (fetched 2026-11-13), table "Resource limits based on app standby bucket"
    On Android 13+ an app the user does not interact with for 8 days goes to the restricted bucket. Exempt: apps the user set to "unrestricted", apps on the Doze exemption list, apps with active widgets. Tapping a notification promotes to Active; swiping it away does not. developer.android.com/topic/performance/appstandby (fetched 2026-11-13): "Android 13 reduces the number of days to 8"; "Apps with active widgets"; "If the user swipes away the notification without tapping on it, the system doesn't consider that action to be an interaction"
    Huawei EMUI 8-10: users must set Battery → App launch → "Manage manually" with all toggles on; developers cannot whitelist; PowerGenie kills apps not on its list. dontkillmyapp.com/huawei (fetched 2026-11-13): "You cannot add custom apps on their pre-defined whitelist"; "It is called PowerGenie and it kills all apps that are not on its whitelist"

    Decisions:

    Topic Decision Reason
    Job type One unique PeriodicWorkRequest (enqueueUniquePeriodicWork("nova-alert-check", UPDATE, …)), a CoroutineWorker. One job checks all servers; UPDATE applies a changed interval or network option without restarting the period.
    Interval 15 min, 30 min (default), 1 h, 3 h. Flex = max(5 min, interval / 2). Unraid's own monitor already evaluates every minute on the server (default.cfg:53 system="*/1 * * * *"); the phone only needs to notice a result. 30 min keeps 48 checks/day; a wide flex lets Android batch the job with other apps' jobs.
    Constraints NetworkType.CONNECTED; UNMETERED when "Only on Wi-Fi" is on. No requiresBatteryNotLow, no requiresCharging. A failing disk is worth knowing at low battery too.
    Retry The worker always returns Result.success(); never retry(). A retry adds wake-ups with exponential backoff; the next period is the retry.
    Expedited work / foreground service Not used. A check takes seconds and has no user waiting. Expedited quota is meant for user-visible short tasks; since Android 12 a foreground service cannot be started from the background, and a persistent WebSocket (notificationAdded subscription, ADR-0026) would need a permanent foreground notification.
    Exact alarms Not used. Not needed for a 15-minute-or-longer cadence; exact alarms need a special permission on Android 12+.
    Boot No own receiver. WorkManager's RescheduleReceiver reschedules after boot (it receives BOOT_COMPLETED, which arrives after the first unlock). Verified manifest above.
    Force stop / OEM kill AlertScheduler.reconcile() runs on every app start (from MainViewModel init) and re-enqueues with UPDATE when the switch is on, or cancels when it is off. Repairs a cancelled schedule the next time the user opens NOVA.
    Initialisation On-demand: remove androidx.work.WorkManagerInitializer from InitializationProvider in the app manifest (tools:node="remove" on that meta-data), let NovaApp implement Configuration.Provider. With the switch off, WorkManager is never touched: no androidx.work.workdb, no 10-year alarm, no force-stop bookkeeping. That is what "the app does no background work, as today" means in practice. The only cost is ~10 lines.
    Worker construction A plain CoroutineWorker that reads its dependencies through a Hilt @EntryPoint (EntryPointAccessors.fromApplication). No androidx.hilt:hilt-work. No extra artifact or annotation processor, no custom WorkerFactory; the house test pattern (internal constructor with lambdas, UpdateTracker.kt:50-56) still applies to the logic behind it.
    FOREGROUND_SERVICE Strip it with <uses-permission android:name="android.permission.FOREGROUND_SERVICE" tools:node="remove" />, guarded by a manifest test like NoInstallPermissionTest. NOVA never starts a foreground service; least privilege and a shorter permission list in the privacy policy. WAKE_LOCK, RECEIVE_BOOT_COMPLETED and ACCESS_NETWORK_STATE stay (WorkManager needs them).
    Battery exemption Do not declare REQUEST_IGNORE_BATTERY_OPTIMIZATIONS. Show the state (PowerManager.isIgnoringBatteryOptimizations, and the standby bucket via UsageStatsManager.getAppStandbyBucket() on API 28+, neither needs a permission) and a button that opens Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS. The store flavour goes to F-Droid and later Google Play (ADR-0040); Play forbids the direct request unless the core function is affected (developer.android.com doze-standby page, as reported by the research lane; not re-fetched). The settings intent needs no permission and works in both stores.

    What the user must be told (in the settings section, plain English):

    • "Android decides when a check runs. When the phone lies still, a check can be an hour or more late."
    • "Battery saving can stop the checks." With the state line "Battery: optimised" or "Battery: not restricted", and a button "Open battery settings".
    • On Huawei, Xiaomi, Samsung and similar: a link "Tips for your phone" that opens https://dontkillmyapp.com in the browser (user action only; NOVA itself makes no request).
    • "For instant alerts, let Unraid send them itself" with a link to the README recipe (2.2).

    2.2 UnifiedPush or Unraid's own agents (do not build)

    What Unraid offers (verified):

    • 14 built-in notification agents: Bark, Boxcar, Discord, Gotify, Join, Prowl, PushBits, Pushbullet, Pushover, Pushplus, ServerChan, Slack, Telegram, ntfy.sh. No generic webhook. (timeout 120 gh api 'repos/unraid/webgui/contents/emhttp/plugins/dynamix/agents' --jq '.[].name')
    • Agents get EVENT, SUBJECT, DESCRIPTION, IMPORTANCE, CONTENT, LINK, TIMESTAMP as environment variables (scripts/notify:278).
    • The ntfy agent sends only Priority, Icon (an image hosted on raw.githubusercontent.com), Authorization: Bearer $NTFY_TOKEN and Title, plus the body. It sends no Click header and ignores $LINK (agents/ntfy.sh.xml:33-39).
    • Agents are off by default: every importance is set to 1 = browser only (default.cfg:54-57 entity="1" normal="1" warning="1" alert="1"; scripts/notify:266 ($entity & 1) = browser, :277 & 2 = e-mail, :278 & 4 = agents).

    UnifiedPush: the spec requires RFC 8291-encrypted messages from an application server. Unraid has no such sender, and the project excludes a relay or plugin that could add one. A "wake-up only" path (Unraid's ntfy agent posting plain text to an ntfy UnifiedPush topic, NOVA then running a check) is outside the spec and untested; the agent always sends a Bearer header, and how the ntfy server treats it on an anonymous UnifiedPush topic is unverified.

    Decision: no UnifiedPush code and no own issue for it. It would cost a distributor dependency and a second code path for an off-spec use, while a user who already runs ntfy gets instant alerts today with configuration only (Unraid agent → own ntfy server → ntfy app, which uses no Firebase for self-hosted servers per docs.ntfy.sh, as reported by the research lane). NOVA's documentation gets a short recipe in the documentation PR (section 6). Revisit only if Unraid gains a Web Push sender or a generic webhook.

    Optional outward-facing step (needs the maintainer's explicit go): a small upstream PR to unraid/webgui adding -H "Click: $LINK" to the ntfy agent, so a tap opens the server's web UI. Not needed for NOVA.


    3. Events

    3.1 What Unraid already notifies about (verified)

    Unraid's monitor (emhttp/plugins/dynamix/scripts/monitor, runs every minute) creates notifications, each with a "returned to normal" counterpart:

    Event Importance Source
    Disk or device hot / overheated (per-disk or global thresholds) warning / alert monitor:236
    SMART attribute warning monitor:271, 288
    Disk utilisation high / low on space warning / alert monitor:315
    Disk in error state (red), only while the array is not stopped alert monitor:346-349 if (_var($var,'fsState')!='Stopped')
    Array has disks with read errors warning monitor:415
    BTRFS pool missing devices / too many profiles warning monitor:379, 386
    Parity check (or sync, rebuild, clear) started warning monitor:447 -i \"warning\"
    Parity check finished; warning if it failed or found errors, normal otherwise normal / warning monitor:461-463 $level = ($status == 0 && $var['sbSyncErrs'] == 0) ? "normal" : "warning"
    Boot drive not read-write alert monitor:476
    Pool degraded or faulted / pool has errors alert / warning monitor:503, 507
    Docker image utilisation warning / alert monitor:538, 543

    Also: the API never fills ParityCheck.errors (schema.graphqls, comment above enum ParityCheckStatus: "running, paused and errors below are declared but the Unraid API never fills them"). So "parity check finished with errors" is only knowable from Unraid's notification.

    3.2 Events in version one (decision)

    # Event Detected from (read-only key) Severity → channel
    E1 A new unread Unraid notification of importance ALERT GetNotifications → notifications.warningsAndAlerts { id … importance … } Alert
    E2 A new unread Unraid notification of importance WARNING (off when the user picks "Alerts only") same Warning
    E3 Array is not started (attentionItems key array-stopped) GetArray → array.state == STOPPED via PR #336 Warning
    E4 Array is degraded (attentionItems key array-degraded; states DISABLE_DISK, INVALID_EXPANSION, PARITY_NOT_BIGGEST, TOO_MANY_MISSING_DISKS) GetArray → array.state, mapped at GraphQlMapper.kt:527-528 Alert

    Why only these:

    • Disk temperature, disk errors, SMART, parity errors, pools, boot drive, utilisation arrive as E1/E2 from Unraid's monitor, computed on the server with the user's own thresholds. Raising them again from GetArray would post every problem twice. (PR feat: a needs-attention summary on the Overview #336's card still shows them in the app.)
    • E3/E4 are the gap: the monitor stays silent about disks while the array is stopped (monitor:346), and nothing notifies "array not started" (for example after a power cut with an encrypted array waiting for its passphrase).
    • Containers stopped or unhealthy: not in v1. The API has no health field in NOVA's schema (enum ContainerState { RUNNING PAUSED EXITED }); "was running, now stopped" is noisy because backup plugins, updates and the user stop containers on purpose, and PR feat: a needs-attention summary on the Overview #336 already rejected auto-start as a signal (Attention.kt:48-50). Unraid's own Docker image notifications still arrive through E1/E2.
    • INFO notifications: never. They include "returned to normal" and update notices.
    • "Cannot reach the server": never notified in v1. The phone cannot tell "server down" from "phone away from home" (connection mode is one global value with no automatic fallback, SettingsStore.kt:30, UnraidRepository.kt:1107-1110), and false alarms teach users to ignore NOVA. The settings line shows it instead. Revisit when a server has a remote address and both addresses failed for a long time, if users ask.

    Known noise the user accepts with the default: a scheduled parity check posts one WARNING when it starts (monitor:447). The settings text names this; "Alerts only" removes it but also drops parity-errors and SMART warnings (product question 2).

    3.3 Detection and duplicate avoidance

    Per server, a persisted ServerAlertState:

    data class ServerAlertState(
        val notificationsBaselined: Boolean = false,
        val seenNotificationIds: List<String> = emptyList(), // insertion order, capped at 300
        val conditionsBaselined: Boolean = false,
        val openConditions: Set<String> = emptySet(),        // subset of {"array-stopped", "array-degraded"}
        val lastCheck: CheckRecord? = null,                  // time + outcome, for the settings line
    )

    Rules (all in one pure function, section 3.5):

    1. Baseline. The first successful notification read for a server after the switch is turned on (or after the server is added) records every current id as seen and posts nothing. Same for conditions. Turning the switch on must not dump old unread items on the user; the in-app bell still shows them.
    2. Notifications. Post each id in warningsAndAlerts that is not in seenNotificationIds and whose importance passes the level filter. Then seen = (seen + all current ids), oldest dropped beyond 300. An id is never posted twice, even if it leaves the unread list and returns. Ids are the notification file names <event>-<timestamp>.notify (notifications.service.ts:925-926 getIdFromPath = basename(path); scripts/notify:208,252 $ticket = $timestamp), so a new occurrence of the same problem has a new id and is posted again, which is intended.
    3. Conditions. Post a condition key that is in the current attentionItems and not in openConditions; set openConditions to the current set. While it holds nothing is repeated; once it clears, the next occurrence is posted again.
    4. Partial results. If GetNotifications failed, keep the notification half of the state unchanged; if GetArray failed, keep the condition half unchanged. A failed or unreachable check never changes state, so items created during an outage are posted when the server is reachable again, and nothing is re-posted.
    5. Flood cap. At most 5 individual notifications per server per check; the rest fold into the server's group summary ("and 3 more on Tower").
    6. Filtered-out ids are still marked as seen, so switching from "Alerts only" to "Alerts and warnings" does not post old warnings.

    Caveats (verified, documented in the README):

    • A notification appears as unread only if "Browser" delivery is ticked for its importance in Unraid (the default); otherwise only an archive copy is written (scripts/notify:265-266) and the API's archive list shows it (notifications.service.ts:705-716). v1 reads unread only. Users who turned Browser off get no E1/E2.
    • warningsAndAlerts is deduplicated by content and limited to 50 (notifications.service.ts:741-749). It returns the newest id for repeated identical content; beyond 50 distinct unread warnings and alerts, older ones are not seen. Acceptable for an alert feature.
    • Unraid keeps notifications in /tmp/notifications (RAM) unless the user stores them on the boot drive (default.cfg:52); a reboot of the server clears unread items. Ids stay unique, so no re-post results.

    3.4 Several servers, addresses, failures

    • All saved servers are checked, one after another, each with its own state. A per-server opt-out is not in v1 (the second server is usually a backup box the user also wants to hear from; easy to add later).
    • Address order (decision): local URL first, then the remote URL if it is set and differs. The check ignores the in-app Local/Remote toggle, which can be stale and is not the phone's real location. Trust per address uses the existing pure trustFor(mode, trustSelfSignedLocal, url, pin) (Tls.kt:147-160) with mode = Local for the local URL and Remote for the remote one, so pinned self-signed trust never applies to the remote address. Cost away from home: one failed connect (connect timeout 8 s, ApolloClientFactory.kt:56) before the remote address.
    • Outcomes per server, recorded for the settings line, never notified: Ok, Unreachable (all addresses failed), CertificateProblem (pinned certificate changed or untrusted: the check fails closed, never prompts and never pins; Tls.kt:125-126, every production caller passes acceptFirstUse = false, Tls.kt:87-91), KeyMissing / KeyUndecryptable (ApiKeyResult, ApiKeyStore.kt:42-46; never resets keys from the background, ADR-0035), Rejected (GraphQL or HTTP error, for example a revoked key).
    • Bounded run: each server gets at most 45 s; the job ends well inside the 10-minute job limit.

    3.5 Shared evaluation for the Overview card (#319) and a widget (#322)

    PR #336 adds the pure attentionItems(AttentionSnapshot(array, notifications, thresholds)): List<AttentionItem> with stable condition keys (Attention.kt:15-19, 56). Alerts wrap it; they do not fork it:

    // data/alerts/AlertRules.kt — pure, no Android
    val STATE_ALERT_KEYS = setOf("array-stopped", "array-degraded")
    
    fun evaluateAlerts(
        server: ServerRef,                       // id + display name
        previous: ServerAlertState,
        notifications: Notifications?,           // null = not loaded this time
        array: ArrayInfo?,                       // null = not loaded this time
        level: AlertLevel,                       // AlertsOnly | AlertsAndWarnings
    ): AlertEvaluation                           // toPost (max 5), folded count, next state

    Inside, the condition half calls attentionItems(AttentionSnapshot(array, notifications = null, thresholds = null)) and keeps STATE_ALERT_KEYS, so the wording and rules are the card's own. The notification half works per id, because PR #336's notification items are count-based ("notifications-alert", Attention.kt:131) and cannot say which notification is new.

    For the widget (#322): the one-shot ServerProbe (section 4.4) returns the same ArrayInfo and Notifications the card uses. A widget later calls attentionItems() on that result and stores its own cache with a timestamp; v1 writes no cache nobody reads. Note: an active widget exempts NOVA from the restricted bucket (appstandby page above), which helps alerts too.


    4. Android details

    4.1 Permission flow

    • Declare android.permission.POST_NOTIFICATIONS in the main manifest. minSdk is 26 (app/build.gradle.kts:17), so notification channels always exist.
    • Android 13+ (API 33+): notifications are off by default after install (developer.android.com notification-permission, fetched 2026-11-13: "your app's notifications are off by default"). Flow when the user turns the switch on: (1) NOVA's explanation dialog (text in section 5); (2) on "Turn on", the system request via ActivityResultContracts.RequestPermission; (3) granted → save the setting, create channels, enqueue the job; denied or dismissed → the switch stays off and the section shows "Android blocks notifications for NOVA" with "Open notification settings" (Settings.ACTION_APP_NOTIFICATION_SETTINGS). After two denials the system no longer shows its dialog; the settings button is the only way.
    • Android 8 to 12: no runtime permission. NOVA shows the same explanation dialog, then checks NotificationManagerCompat.areNotificationsEnabled(); if the user had blocked NOVA's notifications in system settings, the same "blocked" line and button appear.
    • Revoked later: the worker checks areNotificationsEnabled() first. If false it makes no network request, records "Blocked in Android settings", and cancels the job. reconcile() re-enqueues on the next app start once notifications are allowed again.
    • targetSdk 37 (later): apps targeting API 37 need the runtime permission ACCESS_LOCAL_NETWORK for LAN connections; without it "TCP Connections will typically result in a timeout error" (developer.android.com/privacy-and-security/local-network-permission, fetched 2026-11-13). NOVA targets 36 (app/build.gradle.kts:18), so nothing changes now; the ADR records that the target bump must add the permission and treat a blocked LAN as "cannot check", not as "server down". Requesting it before targeting 37 is explicitly discouraged by that page.

    4.2 Channels, content, tap targets, lock screen

    • Channels, created when the switch is first turned on (not at app start):
      • alerts "Server alerts", IMPORTANCE_HIGH (sound, heads-up) — E1, E4, and test notifications.
      • warnings "Server warnings", IMPORTANCE_DEFAULT — E2, E3.
      • No channel for errors or status: unreachable servers are never notified.
        Channel importance cannot be changed after creation; the user owns it from then on.
    • Content of E1/E2: title = Unraid's subject (for example "Warning [Tower] - disk1 is hot (52 C)"), text = description, sub-text = NOVA's server name, setWhen from the notification's ISO timestamp (notifications.service.ts:953). E3/E4: title and detail from attentionItems ("Array is not started", "Start it on the Array tab."). setAutoCancel(true). No action buttons: "Mark as read" would need a write key.
    • Grouping: setGroup("server:<id>") plus a group summary per server when two or more are posted.
    • Android notification id = stable hash of serverId + "|" + key, tag = server id, so all of a server's notifications can be cancelled by tag (NotificationManager.getActiveNotifications(), API 23+).
    • Tap target: explicit intent to MainActivity with extras nova.serverId and nova.target (Array or Notifications, i.e. PR feat: a needs-attention summary on the Overview #336's AttentionTarget), PendingIntent.FLAG_IMMUTABLE | FLAG_UPDATE_CURRENT, request code = notification id, FLAG_ACTIVITY_SINGLE_TOP | FLAG_ACTIVITY_CLEAR_TOP. MainActivity handles onCreate and onNewIntent and hands the request to a small singleton PendingNavigation that MainViewModel consumes: switch the active server when it differs (a deliberate, visible change of the selection), select the Array tab or open the notifications sheet. The sheet's open state moves from remember in MainScreen.kt:140 into the view model for this. Today MainActivity.kt:14-22 handles no extras and AppNavGraph.kt:20-27 has no deep links.
    • Lock screen: VISIBILITY_PRIVATE with a public version "Server alert" / "Unlock to see details", without server name or content. The user can override per channel.

    4.3 API key and certificate in the background (verified in code)

    • The API key is Tink AEAD ciphertext in DataStore, the keyset is wrapped by an Android Keystore key created by AndroidKeystoreKmsClient.generateNewAeadKey (ApiKeyStore.kt:83), which in Tink 1.24.0 uses only setKeySize(256).setBlockModes(GCM).setEncryptionPaddings(NONE) (tink-java v1.24.0 AndroidKeystore.java:51-58): no user-authentication and no unlocked-device requirement. A worker can decrypt while the screen is locked.
    • The app is not direct-boot aware (no directBootAware in AndroidManifest.xml; WorkManager's components are directBootAware="false", work manifest :39, :44, :52, :58), and all data is in credential-encrypted storage. Nothing runs after a reboot until the first unlock; after that the schedule resumes. The design promises nothing before the first unlock.
    • Worker read path: SettingsStore.serverSnapshot (servers + pins, one consistent read, SettingsStore.kt:76-78), ApiKeyStore.getResult(id) per server, trustFor(...), ApolloClientFactory.build(url, key, trust), response classification with the existing public pure classifyResponse (ResponseClassifier.kt:36). It must not use UnraidRepository's streams: domainStream raises the in-app certificate prompt (UnraidRepository.kt:837-847).
    • Building the Hilt graph from the worker starts no GitHub traffic: UpdateTracker starts only when injected (UpdateTracker.kt:109), and the worker's entry point does not inject it.
    • Interaction with Optional biometric app lock #323 (biometric app lock): the lock must gate the UI only. If Optional biometric app lock #323 bound the Keystore key to user authentication (setUserAuthenticationRequired) or to an unlocked device, background checks could no longer decrypt the key. Record this as a constraint on Optional biometric app lock #323.

    4.4 The one-shot seam

    New @Singleton class ServerProbe internal constructor(...) in data/alerts/, with lambdas for "read key", "build client", "run query" (house pattern of UpdateTracker), and an @Inject constructor for production:

    suspend fun probe(server: Server, pin: String?): ProbeResult
    // ProbeResult = Ok(notifications: Notifications?, array: ArrayInfo?, address: Address) | Unreachable | CertificateProblem | KeyMissing | KeyUndecryptable | Rejected(message)

    It sends the two existing operations GetNotifications (queries.graphql:96-108, mapper GraphQlMapper.kt:228) and GetArray (queries.graphql:68-77, mapper toArrayInfo) as two requests over one connection. No new GraphQL document, so ADR-0017's all-or-nothing risk stays per query, and the request-limit test on branch test/graphql-request-limits keeps covering them (that branch's HANDOFF note: GetArray cost 59 of 1,000; "All 15 shipped queries answer HTTP 200 with data" on Unraid 7.4.0; not merged, medium confidence). warningsAndAlerts exists in API 4.30.0, NOVA's minimum (unraid/api v4.30.0 api/generated-schema.graphql:2095).

    4.5 Read-only key

    Upstream, every notification read field and array require READ_ANY on NOTIFICATIONS / ARRAY (notifications.resolver.ts:28-69, array.resolver.ts:15-18), and the built-in VIEWER role has READ_ANY on every resource except API_KEY (auth/casbin/policy.ts:3-7), at unraid/api main 8e587fe2f8 (2026-11-04). So a VIEWER key, or a custom key with notifications:read and array:read, is enough. To be confirmed on the device with the maintainer's read-only key (acceptance A1); feeds #316.

    4.6 Data and battery cost (estimate, not measured)

    Per server and check: two HTTPS POSTs over one connection. Request bodies under 1 KB each with headers. Responses: GetNotifications grows with unread warnings and alerts (about 0.3 KB each, at most 50); GetArray about 0.3 KB per disk. A full TLS handshake each time (a 15-minute gap exceeds OkHttp's default idle keep-alive; library default, not set in the repo). Order of magnitude: 3–15 KB per server per check, so about 0.15–0.7 MB per server per day at 30 minutes, four times that at 15 minutes. Battery cost is dominated by waking the radio; JobScheduler batches the job with other apps' jobs. Acceptance A9 measures both on the test phone; the estimate is not to be published until measured.

    4.7 What is cancelled when

    Event Action
    Switch turned off cancelUniqueWork("nova-alert-check"), cancel any running test work, cancel all posted NOVA notifications, clear ServerAlertState (so turning on again starts with a fresh baseline). Channels stay (deleting them shows "categories deleted" in Android settings).
    Server removed (ServerRepository.delete, ServerRepository.kt:67-75) Drop that server's state, cancel its posted notifications by tag. If no server remains: cancel the job (the switch keeps its value; adding a server re-enqueues via reconcile()).
    Server added Nothing posted until its first baseline check.
    Notifications blocked in Android Worker cancels the job; reconcile() re-enqueues when allowed again.
    Key storage reset (resetKeyStorage) No special action: checks report "key missing" until keys are re-entered.

    5. Settings UI

    New section "Notifications" in SettingsScreen.kt between "Layout" (:224) and "Updates" (:258), using the existing Toggle helper (:497-505). Settings follow the includePrereleases pattern (SettingsStore.kt:37, 198-204). Defaults: off, 30 minutes, "Alerts and warnings", Wi-Fi only off.

    Texts (plain English):

    • Switch "Alerts from your servers"
      • off: "Off. NOVA does nothing in the background."
      • on: "On. NOVA checks your servers about every 30 minutes."
    • Explanation dialog before the permission request:
      • Title: "Get alerts outside the app?"
      • Body: "While this is on, NOVA asks each of your servers about every 30 minutes whether something needs attention: new Unraid alerts and warnings, and an array that is not started or is degraded. The checks go only to your own servers, with the API key you saved, and a read-only key is enough. Android decides the exact time, so an alert can come late, sometimes by an hour or more while the phone is idle. Turn this off at any time and NOVA stops all background checks."
      • Buttons: "Turn on" / "Not now"
    • Options, shown only while on:
      • "Check every": 15 minutes / 30 minutes / 1 hour / 3 hours. Help: "More often uses a little more battery."
      • "Alert me about": "Alerts and warnings" / "Alerts only". Help under the first: "Unraid also sends a warning each time a parity check starts."
      • "Only on Wi-Fi": "Skip checks on mobile data." (maps to unmetered networks)
      • "Send test notification": "Checks now and shows its newest unread alert or warning as a test." Runs the real check once for the active server as a one-time job (same worker, test = true, never touches the seen state). Shows "Test: ", or "Test: NOVA reached . No unread alerts or warnings.", or "Test: NOVA could not reach ." This exercises key decryption in the worker, the network path, the channel and the tap target with a read-only key.
      • Last check line: "Last check: today 14:05. Tower: OK. Backup: could not be reached." Other outcomes: "certificate changed, open NOVA", "API key missing", "blocked in Android settings", "not run yet".
      • Battery line (when optimised): "Battery saving can delay or stop the checks." Buttons "Open battery settings" and "Tips for your phone" (opens dontkillmyapp.com in the browser).
      • "Instant alerts" link: "Unraid can also send alerts itself, for example to your own ntfy server." → README recipe.
    • Blocked state: "Android blocks notifications for NOVA." Button "Open notification settings".

    6. Documentation

    ADR-0047 "Opt-in background alert checks" (amends ADR-0017's "backgrounded app = zero polls", only while the switch is on; the screen streams keep their lifecycle gating):

    • Context: maintainer decision 2026-11-13; no background work existed before; Unraid's monitor is the event source.
    • Decision: sections 1–4 in short (periodic WorkManager job, on-demand init, two existing queries, E1–E4, baseline and seen-ids rule, no unreachable notifications, fail-closed certificate, no battery-exemption permission, FOREGROUND_SERVICE stripped).
    • Consequences: late alerts under Doze and OEM battery managers; 8-day restricted bucket on Android 13+; three merged WorkManager permissions; must add ACCESS_LOCAL_NETWORK when targeting 37; Optional biometric app lock #323 must not bind the key to user authentication.
    • Alternatives: UnifiedPush (no RFC 8291 sender in Unraid), FCM (no Google services), foreground service with the notificationAdded subscription (permanent notification, foreground-service limits), server plugin or relay (excluded by principles), no alerts plus docs only.
    • Revisit when: Unraid ships a Web Push sender or generic webhook; the widget (Home-screen widget with server health and array capacity #322) needs its own refresh; setJobSchedulerPersistenceEnabled (androidx-main commit f8d4af12be, 2026-11-09, not released) becomes stable.
    • Add the row to docs/adr/README.md.

    Privacy policy (docs/privacy/POLICY.md): new effective date (:3); under "Data the app handles" (:11-14) add "Alert state: the ids of server notifications NOVA has already shown, the last check time and result, per server, on the device, excluded from backup and device transfer" (the whole datastore directory is already excluded, backup_rules.xml:18, data_extraction_rules.xml:17,23); under "Data the app sends" (:18-20) add "Only while you turn on Alerts from your servers: NOVA asks each of your servers for its unread alerts and warnings and its array state about every 15 minutes to 3 hours (your choice), in the background. These requests go only to your own servers. Nothing else is sent, and nothing is sent at all while the switch is off." Change "No background tracking of any kind" (:38) to "No background tracking of any kind. The only background work is the optional server check described above." Permissions list (:44-49): add POST_NOTIFICATIONS (Android 13+, asked only when you turn alerts on), and the three WorkManager permissions WAKE_LOCK, RECEIVE_BOOT_COMPLETED (re-plan the checks after a restart, only active while alerts are on) and note ACCESS_NETWORK_STATE (already listed).

    README: privacy bullet (README.md:116) gets the background-check sentence; a new short section "Alerts" with: what is checked, the read-only key, the Browser-delivery caveat, the late-alert and battery caveat, the Huawei/Xiaomi/Samsung hint, and the "Instant alerts with Unraid's own agents" recipe (Settings → Notifications in Unraid → ntfy.sh agent with your own server and topic → tick "Agents" for warnings and alerts → subscribe in the ntfy app; the agent adds an icon URL hosted on GitHub). Same facts on site/index.html (privacy block around :405-413, features :198-199) and fastlane/metadata/android/en-US/full_description.txt:20.

    CHANGELOG (## [Unreleased] → Added):
    "Alerts outside the app (opt-in) — Settings → Notifications → Alerts from your servers checks your servers in the background and tells you about new Unraid alerts and warnings and an array that is not started or is degraded. It is off by default; while it is off, NOVA does nothing in the background. The checks go only to your own servers and a read-only API key is enough. Android decides when a check runs, so an alert can come late (#321)."


  4. nofuturekid commented on Nov 13, 2026

    @nofuturekid
    CollaboratorAuthor

    Design (2026-11-13), part 2 of 2

    7. Pull requests, file ownership and tests

    Order: PR 0 can land any time (docs). PR 1 waits for #336 on main. PR 2 stacks on PR 1, PR 3 on PR 2, PR 4 lands with PR 3, before the version bump. PRs 1–3 change the APK: ./scripts/local-ci.sh green before push (R-BUILD), and none lands while a beta waits for acceptance (R-FREEZE).

    PR Owns (no file in two PRs) Content
    PR 0 docs docs/adr/0047-opt-in-background-alert-checks.md, docs/adr/README.md ADR-0047 as "Proposed", flipped to "Accepted" in PR 4.
    PR 1 rules and state data/alerts/AlertRules.kt, data/alerts/AlertState.kt, data/alerts/AlertStateStore.kt, data/alerts/CheckAddresses.kt, data/local/SettingsStore.kt (keys alerts_enabled, alerts_interval_min, alerts_level, alerts_unmetered_only), data/repository/SettingsRepository.kt, tests under app/src/test/.../data/alerts/ Pure evaluateAlerts, checkAddresses, the DataStore alert_state (JSON per server). Nothing wired.
    PR 2 worker and scheduling data/alerts/{AlertCheckWorker,AlertScheduler,ServerProbe,AlertNotifier,AlertsEntryPoint,PendingNavigation}.kt, NovaApp.kt, MainActivity.kt, AndroidManifest.xml, data/repository/ServerRepository.kt (delete hook), ui/screens/main/MainViewModel.kt + MainScreen.kt (navigation request, sheet state, reconcile() at start), gradle/libs.versions.toml, app/build.gradle.kts (androidx.work:work-runtime latest stable, androidx.work:work-testing for tests) Everything that runs; the switch can only be set by tests.
    PR 3 settings UI ui/screens/settings/SettingsScreen.kt (it also holds SettingsViewModel), new ui/screens/settings/AlertsSection.kt, screenshot tests Section, dialog, permission flow, test action, last-check and battery lines.
    PR 4 documentation docs/privacy/POLICY.md, README.md, site/index.html, fastlane/metadata/android/en-US/full_description.txt, ADR status Section 6. Each code PR adds its own CHANGELOG bullet; PR 3 carries the user-facing one above.

    Tests (each states the reason it exists):

    PR 1 (JVM, plus Robolectric for the store):

    • first_check_posts_nothing_and_records_baseline — turning alerts on must not flood the user with old unread items.
    • new_id_is_posted_once_and_never_again_even_after_leaving_unread — the maintainer's "never re-alert the same notification".
    • info_is_never_posted and alerts_only_drops_warnings_but_marks_them_seen — switching levels must not post old warnings.
    • array_stopped_posted_on_transition_not_while_it_lasts_and_rearmed_after_clearing.
    • disk_temperature_and_disk_error_items_are_not_posted — Unraid already notifies these; posting both doubles every alert. This test fails if someone widens STATE_ALERT_KEYS without deciding it.
    • failed_half_keeps_its_state (notifications failed / array failed) — an outage must neither drop nor repeat alerts.
    • more_than_five_new_fold_into_summary, seen_ids_are_capped_at_300_oldest_first.
    • addresses_local_first_then_remote_and_pin_only_on_local_https — pinned trust must never apply to the remote address (reuses trustFor).
    • store_survives_restart_and_drops_removed_server (Robolectric DataStore).

    PR 2 (Robolectric with work-testing):

    • switch_off_enqueues_nothing_and_does_not_initialise_workmanager — the maintainer's rule; a manifest test also asserts that androidx.work.WorkManagerInitializer is removed and FOREGROUND_SERVICE carries tools:node="remove", and that POST_NOTIFICATIONS is declared.
    • turning_off_cancels_work_and_posted_notifications, removing_last_server_cancels_work.
    • interval_change_updates_the_existing_work (one unique work, new period).
    • certificate_change_reports_problem_and_never_pins_or_prompts — background trust must fail closed (ADR-0041).
    • undecryptable_key_reports_and_never_resets — ADR-0035.
    • blocked_notifications_mean_no_network_request — no background traffic without a visible purpose.
    • worker_always_returns_success — no backoff wake-ups.
    • tap_intent_carries_server_and_target_and_is_immutable.

    PR 3: view-model tests denied_permission_leaves_switch_off, test_action_does_not_touch_seen_state; Roborazzi screenshots of off, on, blocked and battery-optimised states.


    8. Device acceptance (an Android 9 phone; read-only key; a real server)

    Nothing on the server is changed. Package io.github.nofuturekid.nova.

    # Check How Pass
    A0 Switch off = nothing Fresh install of the beta, add the server, do not touch the switch. adb shell dumpsys jobscheduler | grep -A4 io.github.nofuturekid.nova; adb shell dumpsys alarm | grep io.github.nofuturekid.nova; adb shell dumpsys package io.github.nofuturekid.nova | grep -i -A3 disabledComponents No job, no alarm. (WorkManager is not initialised, so its receiver state is not changed either.)
    A1 Read-only key works Turn the switch on, tap "Send test notification" A "Test: …" notification with the newest unread warning or alert, or "No unread alerts or warnings". No error.
    A2 Job scheduled adb shell dumpsys jobscheduler One NOVA job with the network constraint; period matches the setting.
    A3 Real check path Force a run: adb shell cmd jobscheduler run -f io.github.nofuturekid.nova <jobId> (job id from A2; command availability on EMUI 9 to be confirmed) Last-check line updates to "OK"; nothing is posted (baseline).
    A4 Tap target Tap the test notification with another server active (if two are configured) NOVA opens that server's notifications sheet.
    A5 Unreachable stays silent Wi-Fi off, mobile data on, server with local address only; force a run Line says "could not be reached"; no notification.
    A6 Reboot Reboot, do not unlock for 20 min; then unlock and do not open NOVA; after 1 interval dumpsys jobscheduler No check before unlock; job present again after unlock. If EMUI blocks the reschedule, set Battery → App launch → NOVA → Manage manually (all on) and repeat; record both results.
    A7 Standby adb shell am set-standby-bucket io.github.nofuturekid.nova rare, then dumpsys deviceidle force-idle; wait; am get-standby-bucket; dumpsys jobscheduler history Record whether the job runs and whether it has network in the rare bucket (the documentation says network is "Disabled" there; behaviour for jobs is unverified). Then adb shell dumpsys deviceidle unforce.
    A8 Switch off cancels Turn the switch off; repeat A0's commands No NOVA job; posted NOVA notifications are gone.
    A9 Cost adb shell dumpsys batterystats --reset; one day with the switch on (30 min); adb shell dumpsys batterystats io.github.nofuturekid.nova and adb shell dumpsys netstats detail (NOVA's uid) Numbers recorded for the README.
    A10 Overnight on EMUI Phone idle overnight, default battery settings, then again with "Manage manually" Record check times from the last-check line; this is the honest latency to document.

    Cannot be provoked read-only on production, and how they are covered instead:

    • New ALERT/WARNING notification (E1/E2): cannot be created with a read-only key (creating or unarchiving a notification is a write). Covered by PR 1 unit tests, by A1 (which renders a real existing notification through the real path), and by the next naturally occurring one: a scheduled parity check raises a WARNING when it starts (monitor:447), so the next scheduled check is a free end-to-end test.
    • Array not started / degraded (E3/E4): would need stopping the array. Covered by unit tests on evaluateAlerts with attentionItems; optionally on a separate test server where stopping the array is harmless (maintainer's call).
    • Certificate change, key undecryptable: unit tests only.
    • Android 13+ permission flow: the test phone runs Android 9. Run PR 3's flow on an Android 14/15 emulator, using the reset commands from the notification-permission page (pm revoke, pm clear-permission-flags … user-set user-fixed).

    9. Risks

    Risk Effect Mitigation
    OEM battery managers (EMUI PowerGenie, MIUI, One UI) stop the job Alerts stop silently Last-check line, battery state line, dontkillmyapp link; no reliability promise; reconcile() at app start
    Doze and standby buckets Alerts late by an hour or more; on Android 13+ once a day after 8 days without opening NOVA Say so in the dialog and README; tapping an alert promotes NOVA to Active; a widget (#322) exempts it; "unrestricted" battery setting hint; ntfy recipe for instant alerts
    Noise from routine warnings (parity check start) Users turn alerts off "Alerts only" option; text names the cause (product question 2)
    Browser delivery turned off on the server No E1/E2 for that importance README caveat; reading the archive is a possible v2
    Double alerts if someone later posts structured disk items too Every problem twice STATE_ALERT_KEYS allow-list with a test that fails when widened
    Unknown field in a query on an older or newer API That half of the check fails Only shipped queries; partial results keep state; request-limit test
    Key not decryptable before the first unlock No checks after a reboot until unlock Documented; nothing to fix
    #323 binds the Keystore key to authentication Background checks break Constraint recorded in ADR-0047 and on #323
    targetSdk 37 LAN checks time out without ACCESS_LOCAL_NETWORK ADR note; treat as "cannot check"
    Lock screen shows server details Privacy Private visibility with a neutral public version
    Rare bucket network "Disabled" Checks may run without network A7 measures it; document the result
    WorkManager adds WAKE_LOCK and RECEIVE_BOOT_COMPLETED to every install Permission list grows even with alerts off Privacy policy names them and says the boot receiver is active only while alerts are on; FOREGROUND_SERVICE stripped

    10. Product questions (only these need the maintainer)

    1. Widget refresh (Home-screen widget with server health and array capacity #322): may the home-screen widget refresh in the background on its own, or only while alerts are switched on (sharing this job) and when the app is opened? Your rule "background checks only while notifications are on" currently forbids the first.
    2. Default level: "Alerts and warnings" (recommended: catches parity checks with errors, SMART warnings and read errors, but also posts one warning each time a parity check starts) or "Alerts only" (quieter, misses those)?
  5. self-assigned this
    on Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestmarket-2026-11From the market overview of 2026-11-13questionFurther information is requested

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions