Recording Control Notifications
System Intent
- What this is: A platform-forked notification system that shows a persistent, high-importance recording-controls notification while a capture session is active. On iOS it uses
expo-notifications; on Android it uses a native foreground service (RecordingControlService). The notification surfaces Start/Stop action buttons so the user can control recording without opening the app.
Mermaid Diagram
flowchart TD
Init[initializeRecordingControlOrchestrator] -->|iOS| IOSInit[initializeIOSNotifications]
Init -->|Android| AndroidInit[WearablesModule.initializeRecordingControls]
IOSInit --> Handler[setNotificationHandler\nshouldShowBanner: true\nshouldShowList: true]
IOSInit --> Categories[setNotificationCategoryAsync\nidle + active categories]
IOSInit --> Perms[requestPermissionsAsync]
AndroidInit --> Channel[ensureNotificationChannel\nIMPORTANCE_HIGH\nencache_recording_controls_v6]
StateChange[syncNativeControlState] -->|iOS| UpdateIOS[scheduleNotificationAsync\ntrigger: null]
StateChange -->|Android| UpdateAndroid[RecordingControlService.startOrUpdate\nvia startForegroundService]
UpdateAndroid --> Builder[NotificationCompat.Builder\nPRIORITY_HIGH\nFOREGROUND_SERVICE_IMMEDIATE]
Flows
Flow: initializeIOSNotifications
- Core files:
main/app/lib/recording-control-orchestrator.ts
Types
NotificationHandlerConfig {
shouldShowBanner: true (required — shows the banner in foreground)
shouldShowList: true (required — retains entry in Notification Center)
shouldPlaySound: false
shouldSetBadge: false
}
NotificationCategory {
identifier: string ("encache-recording-idle" | "encache-recording-active")
actions: NotificationAction[]
}
Paths
| path | input | output | path-type | notes |
initializeIOSNotifications.success | none | handler registered, categories set, permission granted | happy path | iosPermissionGranted = true |
initializeIOSNotifications.permission-denied | none | handler registered, categories set | degraded | iosPermissionGranted = false; no notifications shown |
initializeIOSNotifications.category-error | none | error logged, execution continues | error | category registration failure is non-fatal |
Pseudocode
setNotificationHandler({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false })
setNotificationCategoryAsync(RECORDING_IDLE_CATEGORY, [{ id:"start", opensAppToForeground:true }])
setNotificationCategoryAsync(RECORDING_ACTIVE_CATEGORY, [{ id:"stop", opensAppToForeground:true }])
{ status } = await requestPermissionsAsync()
iosPermissionGranted = status === "granted"
addNotificationResponseReceivedListener → applyExternalAction
Flow: updateIOSNotification
- Core files:
main/app/lib/recording-control-orchestrator.ts
Types
RecordingControlNotificationContent {
title: string
body: string
actions: Array<{ id: "start" | "stop"; label: string }>
}
Paths
| path | input | output | path-type | notes |
updateIOSNotification.success | RecordingControlNotificationContent | notification rescheduled | happy path | old notification dismissed before new one scheduled |
updateIOSNotification.no-permission | RecordingControlNotificationContent | no-op | guard | returns early if iosPermissionGranted === false |
Pseudocode
if (!iosPermissionGranted) return
dismiss + cancel old notification (swallow errors)
scheduleNotificationAsync({ identifier: RECORDING_NOTIFICATION_ID, content: { title, body, categoryIdentifier, sound:false }, trigger: null })
Flow: ensureNotificationChannel (Android)
- Core files:
main/app/wearables-module/android/src/main/java/expo/modules/wearablesmodule/RecordingControlService.kt
Types
NotificationChannel {
id: "encache_recording_controls_v6"
name: "Recording Controls"
importance: IMPORTANCE_HIGH (heads-up / peek display)
vibration: false
sound: null
badge: false
lockscreenVisibility: VISIBILITY_PUBLIC
}
Paths
| path | input | output | path-type | notes |
ensureNotificationChannel.created | none | channel created with IMPORTANCE_HIGH | happy path | only runs on API 26+ |
ensureNotificationChannel.already-exists | none | no-op | guard | channel already registered for this ID |
ensureNotificationChannel.pre-oreo | none | no-op | guard | Build.VERSION.SDK_INT < O |
Flow: buildNotification (Android)
- Core files:
main/app/wearables-module/android/src/main/java/expo/modules/wearablesmodule/RecordingControlService.kt
Types
NotificationBuilderConfig {
priority: PRIORITY_HIGH
foregroundServiceBehavior: FOREGROUND_SERVICE_IMMEDIATE
ongoing: true
silent: true
onlyAlertOnce: true
visibility: VISIBILITY_PUBLIC
category: CATEGORY_SERVICE
}
Paths
| path | input | output | path-type | notes |
buildNotification.with-actions | title, body, actionIds[], actionLabels[] | Notification with action buttons | happy path | action intents sent to RecordingActionReceiver |
buildNotification.no-actions | title, body, empty arrays | Notification without action buttons | valid | idle state has a Start button; recording has a Stop button |
Pseudocode
NotificationCompat.Builder(CHANNEL_ID)
.setPriority(PRIORITY_HIGH)
.setForegroundServiceBehavior(FOREGROUND_SERVICE_IMMEDIATE)
.setOngoing(true)
.setSilent(true)
for each action → addAction(label, PendingIntent → RecordingActionReceiver)
return builder.build()
Flow: RecordingControlService.onStartCommand (Android)
- Core files:
main/app/wearables-module/android/src/main/java/expo/modules/wearablesmodule/RecordingControlService.kt
Paths
| path | input | output | path-type | notes |
onStartCommand.first-start | Intent with title/body/actions | startForeground(NOTIFICATION_ID, notification) | happy path | isForegroundStarted flipped to true |
onStartCommand.update | Intent with title/body/actions | NotificationManagerCompat.notify(NOTIFICATION_ID, notification) | happy path | subsequent calls when service already running |
Notification Channel History
| Channel ID | Importance | Notes |
encache_recording_controls_v5 | IMPORTANCE_LOW | Original — no heads-up display. Retired. |
encache_recording_controls_v6 | IMPORTANCE_HIGH | Current — shows heads-up banner. Channel ID bumped because Android caches importance at channel creation time; existing installs with v5 would ignore an importance upgrade without a new ID. |
Logs
| Source | Location |
| iOS orchestrator | createFlowLogger("recording-control") — step keys: recording_control_ios_initialized, recording_control_ios_notification_update_failed, recording_control_ios_teardown_succeeded |
| Android orchestrator | step keys: recording_control_native_initialize_succeeded, recording_control_native_update_succeeded, recording_control_native_teardown_succeeded |
Deployment
- Mechanism:
local only (shipped as part of the React Native app bundle and native Android module) - Deploy command:
# iOS
npx expo run:ios
# Android
npx expo run:android
- Notes: The Android
NotificationChannel is created once per install per channel ID. Changing IMPORTANCE_HIGH back to a lower value on an existing install requires the user to uninstall or manually reconfigure the channel in system settings. Always bump the channel ID suffix when changing importance.