react-native-alarms 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -1
- package/android-lib/build.gradle +36 -0
- package/android-lib/src/main/AndroidManifest.xml +49 -0
- package/android-lib/src/main/java/com/reactnativealarms/RNAlarmsPackage.kt +18 -0
- package/android-lib/src/main/res/values/styles.xml +3 -0
- package/index.d.ts +6 -2
- package/index.ts +2 -1
- package/ios/ReactNativeAlarm.xcodeproj/project.pbxproj +4 -0
- package/ios/alarm/AlarmKitBackend.swift +94 -27
- package/ios/alarm/AlarmModule.m +25 -0
- package/ios/alarm/AlarmModule.swift +36 -2
- package/ios/alarm/AlarmRingPresenter.swift +126 -0
- package/ios/alarm/NotificationBackend.swift +42 -7
- package/package.json +7 -20
- package/react-native-alarms.podspec +20 -0
- package/react-native.config.js +16 -0
- package/specs/NativeAlarm.ts +20 -2
- package/app.json +0 -4
- package/index.js +0 -12
- package/ios/Podfile.lock +0 -2179
package/README.md
CHANGED
|
@@ -45,7 +45,33 @@ Since this is a native module, you'll need to link it:
|
|
|
45
45
|
cd ios && pod install && cd ..
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
For Android, the native code
|
|
48
|
+
For Android, the native code auto-links, including the permissions, ring activity,
|
|
49
|
+
service and receivers in its manifest. No `MainApplication` or `AndroidManifest.xml`
|
|
50
|
+
changes are needed. Rebuild the app after installing.
|
|
51
|
+
|
|
52
|
+
### Register the ring screen
|
|
53
|
+
|
|
54
|
+
When an alarm rings, both platforms show a full-screen React component registered as
|
|
55
|
+
`AlarmRing`, with the alarm (`alarmId`, `title`, `body`, `snoozeEnabled`,
|
|
56
|
+
`snoozeMinutes`, `data`) as props. Register your own component in `index.js`:
|
|
57
|
+
|
|
58
|
+
```js
|
|
59
|
+
import { AppRegistry } from 'react-native';
|
|
60
|
+
import { AlarmRingScreen } from './src/AlarmRingScreen';
|
|
61
|
+
|
|
62
|
+
AppRegistry.registerComponent('AlarmRing', () => AlarmRingScreen);
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Call `Alarm.stopAlarm(alarmId)` or `Alarm.snoozeAlarm(alarmId, minutes)` from its
|
|
66
|
+
buttons; the screen closes itself.
|
|
67
|
+
|
|
68
|
+
### Direct module access
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import Alarm, { AlarmService } from 'react-native-alarms';
|
|
72
|
+
|
|
73
|
+
const id = await Alarm.scheduleAlarm({ timestamp: Date.now() + 60_000, title: 'Wake up' });
|
|
74
|
+
```
|
|
49
75
|
|
|
50
76
|
## Quick Start
|
|
51
77
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Library module autolinked into consumer apps (see react-native.config.js).
|
|
2
|
+
// Sources stay in the example app's tree; this module only references them.
|
|
3
|
+
apply plugin: "com.android.library"
|
|
4
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
5
|
+
|
|
6
|
+
def safeExtGet(prop, fallback) {
|
|
7
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
android {
|
|
11
|
+
namespace "com.reactnativealarms"
|
|
12
|
+
compileSdk safeExtGet("compileSdkVersion", 35)
|
|
13
|
+
|
|
14
|
+
defaultConfig {
|
|
15
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
16
|
+
targetSdkVersion safeExtGet("targetSdkVersion", 35)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
sourceSets {
|
|
20
|
+
main {
|
|
21
|
+
manifest.srcFile "src/main/AndroidManifest.xml"
|
|
22
|
+
java.srcDirs = ["src/main/java", "../android/app/src/main/java/com/reactnativealarm/alarm"]
|
|
23
|
+
res.srcDirs = ["src/main/res"]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
repositories {
|
|
29
|
+
google()
|
|
30
|
+
mavenCentral()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
dependencies {
|
|
34
|
+
implementation "com.facebook.react:react-android"
|
|
35
|
+
implementation "androidx.appcompat:appcompat:1.7.0"
|
|
36
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
2
|
+
|
|
3
|
+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
|
4
|
+
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
|
5
|
+
<uses-permission
|
|
6
|
+
android:name="android.permission.SCHEDULE_EXACT_ALARM"
|
|
7
|
+
android:maxSdkVersion="32" />
|
|
8
|
+
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
|
9
|
+
<uses-permission android:name="android.permission.VIBRATE" />
|
|
10
|
+
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
|
11
|
+
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
|
12
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
|
13
|
+
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
|
14
|
+
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
15
|
+
|
|
16
|
+
<application>
|
|
17
|
+
<activity
|
|
18
|
+
android:name="com.alarm.AlarmRingActivity"
|
|
19
|
+
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
|
|
20
|
+
android:excludeFromRecents="true"
|
|
21
|
+
android:exported="false"
|
|
22
|
+
android:launchMode="singleInstance"
|
|
23
|
+
android:showWhenLocked="true"
|
|
24
|
+
android:taskAffinity=""
|
|
25
|
+
android:theme="@style/RNAlarmRingTheme"
|
|
26
|
+
android:turnScreenOn="true" />
|
|
27
|
+
|
|
28
|
+
<service
|
|
29
|
+
android:name="com.alarm.AlarmService"
|
|
30
|
+
android:exported="false"
|
|
31
|
+
android:foregroundServiceType="mediaPlayback" />
|
|
32
|
+
|
|
33
|
+
<receiver
|
|
34
|
+
android:name="com.alarm.AlarmReceiver"
|
|
35
|
+
android:exported="false" />
|
|
36
|
+
|
|
37
|
+
<receiver
|
|
38
|
+
android:name="com.alarm.BootReceiver"
|
|
39
|
+
android:enabled="true"
|
|
40
|
+
android:exported="true">
|
|
41
|
+
<intent-filter>
|
|
42
|
+
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
|
43
|
+
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
|
44
|
+
<action android:name="android.intent.action.TIME_SET" />
|
|
45
|
+
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
|
46
|
+
</intent-filter>
|
|
47
|
+
</receiver>
|
|
48
|
+
</application>
|
|
49
|
+
</manifest>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
package com.reactnativealarms
|
|
2
|
+
|
|
3
|
+
import com.alarm.AlarmPackage
|
|
4
|
+
import com.facebook.react.BaseReactPackage
|
|
5
|
+
import com.facebook.react.bridge.NativeModule
|
|
6
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
// Autolinking entry point: the RN CLI only detects ReactPackage classes located inside
|
|
10
|
+
// android-lib/, while the real implementation lives in the example app's source tree.
|
|
11
|
+
class RNAlarmsPackage : BaseReactPackage() {
|
|
12
|
+
private val impl = AlarmPackage()
|
|
13
|
+
|
|
14
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
|
|
15
|
+
impl.getModule(name, reactContext)
|
|
16
|
+
|
|
17
|
+
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = impl.getReactModuleInfoProvider()
|
|
18
|
+
}
|
package/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
* Cross-platform alarm management for React Native
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { Spec } from './specs/NativeAlarm';
|
|
7
|
+
|
|
6
8
|
export type {
|
|
7
9
|
AlarmRepeat,
|
|
8
10
|
AlarmConfig,
|
|
@@ -12,5 +14,7 @@ export type {
|
|
|
12
14
|
Spec as AlarmModuleSpec,
|
|
13
15
|
} from './specs/NativeAlarm';
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
export { AlarmService } from './AlarmService';
|
|
18
|
+
|
|
19
|
+
declare const Alarm: Spec;
|
|
20
|
+
export default Alarm;
|
package/index.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
7A1A000000000000000B0004 /* AlarmStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A000000000000000A0004 /* AlarmStore.swift */; };
|
|
19
19
|
7A1A000000000000000B0005 /* AlarmKitBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A000000000000000A0005 /* AlarmKitBackend.swift */; };
|
|
20
20
|
7A1A000000000000000B0006 /* NotificationBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A000000000000000A0006 /* NotificationBackend.swift */; };
|
|
21
|
+
7A1A000000000000000B0007 /* AlarmRingPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1A000000000000000A0007 /* AlarmRingPresenter.swift */; };
|
|
21
22
|
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
|
|
22
23
|
/* End PBXBuildFile section */
|
|
23
24
|
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
7A1A000000000000000A0004 /* AlarmStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmStore.swift; sourceTree = "<group>"; };
|
|
38
39
|
7A1A000000000000000A0005 /* AlarmKitBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmKitBackend.swift; sourceTree = "<group>"; };
|
|
39
40
|
7A1A000000000000000A0006 /* NotificationBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationBackend.swift; sourceTree = "<group>"; };
|
|
41
|
+
7A1A000000000000000A0007 /* AlarmRingPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmRingPresenter.swift; sourceTree = "<group>"; };
|
|
40
42
|
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = ReactNativeAlarm/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
|
41
43
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
|
42
44
|
/* End PBXFileReference section */
|
|
@@ -84,6 +86,7 @@
|
|
|
84
86
|
7A1A000000000000000A0004 /* AlarmStore.swift */,
|
|
85
87
|
7A1A000000000000000A0005 /* AlarmKitBackend.swift */,
|
|
86
88
|
7A1A000000000000000A0006 /* NotificationBackend.swift */,
|
|
89
|
+
7A1A000000000000000A0007 /* AlarmRingPresenter.swift */,
|
|
87
90
|
);
|
|
88
91
|
path = alarm;
|
|
89
92
|
sourceTree = "<group>";
|
|
@@ -283,6 +286,7 @@
|
|
|
283
286
|
7A1A000000000000000B0004 /* AlarmStore.swift in Sources */,
|
|
284
287
|
7A1A000000000000000B0005 /* AlarmKitBackend.swift in Sources */,
|
|
285
288
|
7A1A000000000000000B0006 /* NotificationBackend.swift in Sources */,
|
|
289
|
+
7A1A000000000000000B0007 /* AlarmRingPresenter.swift in Sources */,
|
|
286
290
|
);
|
|
287
291
|
runOnlyForDeploymentPostprocessing = 0;
|
|
288
292
|
};
|
|
@@ -5,11 +5,34 @@ import SwiftUI
|
|
|
5
5
|
#if canImport(AlarmKit)
|
|
6
6
|
import ActivityKit
|
|
7
7
|
import AlarmKit
|
|
8
|
+
import AppIntents
|
|
8
9
|
|
|
9
10
|
/// Metadata type required by `AlarmAttributes`; extend it if you add a Live Activity widget.
|
|
10
11
|
@available(iOS 26.0, *)
|
|
11
12
|
struct RNAlarmMetadata: AlarmMetadata {}
|
|
12
13
|
|
|
14
|
+
/// Backs the "Open" button on the AlarmKit alert. An AlarmKit alert ignores taps on
|
|
15
|
+
/// its body, so this intent (run in the app process, `openAppWhenRun`) is the only
|
|
16
|
+
/// way the alert can open the app and show the React ring screen.
|
|
17
|
+
@available(iOS 26.0, *)
|
|
18
|
+
struct OpenAlarmAppIntent: LiveActivityIntent {
|
|
19
|
+
static let title: LocalizedStringResource = "Open Alarm"
|
|
20
|
+
static let description = IntentDescription("Opens the ringing alarm's screen")
|
|
21
|
+
static let openAppWhenRun: Bool = true
|
|
22
|
+
static let isDiscoverable: Bool = false
|
|
23
|
+
|
|
24
|
+
@Parameter(title: "alarmID")
|
|
25
|
+
var alarmID: String
|
|
26
|
+
|
|
27
|
+
init() {}
|
|
28
|
+
init(alarmID: String) { self.alarmID = alarmID }
|
|
29
|
+
|
|
30
|
+
func perform() async throws -> some IntentResult {
|
|
31
|
+
AlarmKitBackend.shared.openFromAlert(alarmID)
|
|
32
|
+
return .result()
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
/// Real alarms on iOS 26+: ring through silent mode and Focus with the system alarm UI.
|
|
14
37
|
/// Only compiled with an SDK that ships AlarmKit (Xcode 26+); older toolchains build
|
|
15
38
|
/// the app with the notification fallback only.
|
|
@@ -26,6 +49,10 @@ final class AlarmKitBackend {
|
|
|
26
49
|
/// Alarms we are stopping as part of a programmatic snooze (so the observer
|
|
27
50
|
/// reports "snoozed" instead of "dismissed").
|
|
28
51
|
private var snoozing: Set<UUID> = []
|
|
52
|
+
/// Alarms stopped only to silence the system sound while the in-app ring screen
|
|
53
|
+
/// (with its own TTS) is showing. The ring session stays alive until JS calls
|
|
54
|
+
/// stop/snooze, so the observer must not report these as dismissed.
|
|
55
|
+
private var silenced: [UUID: AlarmData] = [:]
|
|
29
56
|
|
|
30
57
|
private static let allWeekdays: [Locale.Weekday] = [
|
|
31
58
|
.sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
|
|
@@ -98,23 +125,16 @@ final class AlarmKitBackend {
|
|
|
98
125
|
{
|
|
99
126
|
let title = LocalizedStringResource(stringLiteral: a.title.isEmpty ? "Alarm" : a.title)
|
|
100
127
|
let stop = AlarmButton(text: "Stop", textColor: .white, systemImageName: "stop.fill")
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
title: title, stopButton: stop, secondaryButton: snooze, secondaryButtonBehavior: .countdown
|
|
109
|
-
)
|
|
110
|
-
countdown = AlarmPresentation.Countdown(title: "Snoozed", pauseButton: nil)
|
|
111
|
-
duration = Alarm.CountdownDuration(preAlert: nil, postAlert: Double(max(1, a.snoozeMinutes)) * 60)
|
|
112
|
-
} else {
|
|
113
|
-
alert = AlarmPresentation.Alert(title: title, stopButton: stop)
|
|
114
|
-
}
|
|
128
|
+
// The alert's body ignores taps and it allows a single secondary button, so it
|
|
129
|
+
// must be "Open" (via OpenAlarmAppIntent): the React ring screen it opens has
|
|
130
|
+
// its own Snooze/Finish. The system countdown-snooze button is dropped for it.
|
|
131
|
+
let open = AlarmButton(text: "Open", textColor: .white, systemImageName: "arrow.up.forward.app")
|
|
132
|
+
let alert = AlarmPresentation.Alert(
|
|
133
|
+
title: title, stopButton: stop, secondaryButton: open, secondaryButtonBehavior: .custom
|
|
134
|
+
)
|
|
115
135
|
|
|
116
136
|
let attributes = AlarmAttributes<RNAlarmMetadata>(
|
|
117
|
-
presentation: AlarmPresentation(alert: alert
|
|
137
|
+
presentation: AlarmPresentation(alert: alert),
|
|
118
138
|
metadata: RNAlarmMetadata(),
|
|
119
139
|
tintColor: .blue
|
|
120
140
|
)
|
|
@@ -122,16 +142,18 @@ final class AlarmKitBackend {
|
|
|
122
142
|
(a.sound.isEmpty || a.sound == "default") ? .default : .named(a.sound)
|
|
123
143
|
|
|
124
144
|
return AlarmManager.AlarmConfiguration(
|
|
125
|
-
countdownDuration: duration,
|
|
126
145
|
schedule: schedule,
|
|
127
146
|
attributes: attributes,
|
|
147
|
+
secondaryIntent: OpenAlarmAppIntent(alarmID: a.id),
|
|
128
148
|
sound: sound
|
|
129
149
|
)
|
|
130
150
|
}
|
|
131
151
|
|
|
132
152
|
func cancel(_ id: String) {
|
|
133
|
-
|
|
134
|
-
|
|
153
|
+
for uuid in [Self.uuid(for: id), Self.uuid(for: id, snooze: true)] {
|
|
154
|
+
try? manager.cancel(id: uuid)
|
|
155
|
+
silenced.removeValue(forKey: uuid)
|
|
156
|
+
}
|
|
135
157
|
}
|
|
136
158
|
|
|
137
159
|
func cancelAll() {
|
|
@@ -139,19 +161,60 @@ final class AlarmKitBackend {
|
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
func stop(_ id: String) {
|
|
142
|
-
|
|
143
|
-
|
|
164
|
+
for uuid in [Self.uuid(for: id), Self.uuid(for: id, snooze: true)] {
|
|
165
|
+
try? manager.stop(id: uuid)
|
|
166
|
+
// Ended a session we had silenced earlier: the observer saw nothing change,
|
|
167
|
+
// so report the dismissal (and re-arm intervals) here instead.
|
|
168
|
+
if let a = silenced.removeValue(forKey: uuid) {
|
|
169
|
+
onEvent?(AlarmModule.eventDismissed, a)
|
|
170
|
+
if a.repeatType == "interval" { Task { try? await self.schedule(a) } }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
144
173
|
}
|
|
145
174
|
|
|
146
|
-
///
|
|
147
|
-
///
|
|
148
|
-
|
|
175
|
+
/// "Open" tapped on the system alert (OpenAlarmAppIntent). The tap stops the
|
|
176
|
+
/// AlarmKit alarm, so adopt the session as `silenced` — still ringing as far as
|
|
177
|
+
/// JS is concerned — and show the React ring screen once the app is frontmost.
|
|
178
|
+
func openFromAlert(_ id: String) {
|
|
179
|
+
guard let a = AlarmStore.get(id) else { return }
|
|
149
180
|
let current = (try? manager.alarms) ?? []
|
|
150
181
|
for uuid in [Self.uuid(for: a.id), Self.uuid(for: a.id, snooze: true)]
|
|
151
182
|
where current.contains(where: { $0.id == uuid && $0.state == .alerting }) {
|
|
152
|
-
|
|
183
|
+
silenced[uuid] = a
|
|
184
|
+
try? manager.stop(id: uuid)
|
|
185
|
+
}
|
|
186
|
+
// The system may have stopped it before we ran; the session is still ours.
|
|
187
|
+
if !silenced.values.contains(where: { $0.id == a.id }) {
|
|
188
|
+
silenced[Self.uuid(for: a.id)] = a
|
|
189
|
+
}
|
|
190
|
+
DispatchQueue.main.async { AlarmRingPresenter.present(a, bridge: nil) }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/// Stops the system alert's sound/UI while the in-app ring screen takes over.
|
|
194
|
+
/// The session stays "ringing" (see `silenced`) until JS stops or snoozes it.
|
|
195
|
+
func silence(_ id: String) {
|
|
196
|
+
let current = (try? manager.alarms) ?? []
|
|
197
|
+
for uuid in [Self.uuid(for: id), Self.uuid(for: id, snooze: true)]
|
|
198
|
+
where current.contains(where: { $0.id == uuid && $0.state == .alerting }) {
|
|
199
|
+
silenced[uuid] = stored(for: uuid)
|
|
153
200
|
try? manager.stop(id: uuid)
|
|
154
201
|
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/// Silences the ringing alarm (if any) and re-rings once in `minutes`,
|
|
205
|
+
/// equivalent to Android's `AlarmScheduler.scheduleSnooze`.
|
|
206
|
+
func snooze(_ a: AlarmData, minutes: Int) async throws {
|
|
207
|
+
let current = (try? manager.alarms) ?? []
|
|
208
|
+
var wasSilenced = false
|
|
209
|
+
for uuid in [Self.uuid(for: a.id), Self.uuid(for: a.id, snooze: true)] {
|
|
210
|
+
if silenced.removeValue(forKey: uuid) != nil { wasSilenced = true }
|
|
211
|
+
if current.contains(where: { $0.id == uuid && $0.state == .alerting }) {
|
|
212
|
+
snoozing.insert(uuid)
|
|
213
|
+
try? manager.stop(id: uuid)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Snoozed a session we had silenced: the observer saw nothing change, report it here.
|
|
217
|
+
if wasSilenced { onEvent?(AlarmModule.eventSnoozed, a) }
|
|
155
218
|
let date = Date().addingTimeInterval(Double(max(1, minutes)) * 60)
|
|
156
219
|
_ = try await manager.schedule(
|
|
157
220
|
id: Self.uuid(for: a.id, snooze: true),
|
|
@@ -161,10 +224,11 @@ final class AlarmKitBackend {
|
|
|
161
224
|
}
|
|
162
225
|
|
|
163
226
|
func ringingAlarm() -> AlarmData? {
|
|
164
|
-
|
|
165
|
-
return
|
|
227
|
+
if let alerting = ((try? manager.alarms) ?? []).first(where: { $0.state == .alerting }) {
|
|
228
|
+
return stored(for: alerting.id)
|
|
166
229
|
}
|
|
167
|
-
|
|
230
|
+
// Sound silenced for the in-app ring screen, but the session is still live.
|
|
231
|
+
return silenced.values.first
|
|
168
232
|
}
|
|
169
233
|
|
|
170
234
|
// MARK: - Events
|
|
@@ -187,14 +251,17 @@ final class AlarmKitBackend {
|
|
|
187
251
|
for (uuid, state) in states where lastStates[uuid] != state {
|
|
188
252
|
guard let a = stored(for: uuid) else { continue }
|
|
189
253
|
if state == .alerting {
|
|
254
|
+
silenced.removeValue(forKey: uuid) // ringing again supersedes any old silence
|
|
190
255
|
onEvent?(AlarmModule.eventFired, a)
|
|
191
256
|
} else if lastStates[uuid] == .alerting {
|
|
257
|
+
if silenced[uuid] != nil { continue } // silenced for the ring screen, not over yet
|
|
192
258
|
onEvent?(state == .countdown ? AlarmModule.eventSnoozed : AlarmModule.eventDismissed, a)
|
|
193
259
|
}
|
|
194
260
|
}
|
|
195
261
|
|
|
196
262
|
// One-off (`.fixed`) alarms disappear once stopped.
|
|
197
263
|
for (uuid, previous) in lastStates where states[uuid] == nil && previous == .alerting {
|
|
264
|
+
if silenced[uuid] != nil { continue } // silenced for the ring screen, not over yet
|
|
198
265
|
guard let a = stored(for: uuid) else { continue }
|
|
199
266
|
if snoozing.remove(uuid) != nil {
|
|
200
267
|
onEvent?(AlarmModule.eventSnoozed, a)
|
package/ios/alarm/AlarmModule.m
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
#import <React/RCTBridgeModule.h>
|
|
2
2
|
#import <React/RCTEventEmitter.h>
|
|
3
|
+
#import <UIKit/UIKit.h>
|
|
4
|
+
|
|
5
|
+
// The notification delegate must be set before launch finishes, or a tap on an alarm
|
|
6
|
+
// notification that launches the app is lost. JS creates the module lazily, so install
|
|
7
|
+
// it here instead of requiring an AppDelegate change in every app.
|
|
8
|
+
@interface RNAlarmBootstrap : NSObject
|
|
9
|
+
@end
|
|
10
|
+
|
|
11
|
+
@implementation RNAlarmBootstrap
|
|
12
|
+
|
|
13
|
+
+ (void)load
|
|
14
|
+
{
|
|
15
|
+
[[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification
|
|
16
|
+
object:nil
|
|
17
|
+
queue:nil
|
|
18
|
+
usingBlock:^(NSNotification *note) {
|
|
19
|
+
Class backend = NSClassFromString(@"RNAlarmNotificationBackend");
|
|
20
|
+
SEL install = NSSelectorFromString(@"install");
|
|
21
|
+
if ([backend respondsToSelector:install]) {
|
|
22
|
+
((void (*)(id, SEL))[backend methodForSelector:install])(backend, install);
|
|
23
|
+
}
|
|
24
|
+
}];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@end
|
|
3
28
|
|
|
4
29
|
// Exposes the Swift `AlarmModule` to JS as `NativeModules.Alarm`, the same name
|
|
5
30
|
// as Android's `AlarmModule.NAME`. Method names and signatures match Android.
|
|
@@ -16,17 +16,47 @@ final class AlarmModule: RCTEventEmitter {
|
|
|
16
16
|
static let eventSnoozed = "onAlarmSnoozed"
|
|
17
17
|
|
|
18
18
|
private var hasListeners = false
|
|
19
|
+
private var activeObserver: NSObjectProtocol?
|
|
19
20
|
|
|
20
21
|
override init() {
|
|
21
22
|
super.init()
|
|
22
23
|
NotificationBackend.install()
|
|
23
|
-
NotificationBackend.shared.onEvent = { [weak self] name, alarm in self?.
|
|
24
|
+
NotificationBackend.shared.onEvent = { [weak self] name, alarm in self?.handle(name, alarm) }
|
|
24
25
|
#if canImport(AlarmKit)
|
|
25
26
|
if #available(iOS 26.0, *) {
|
|
26
|
-
AlarmKitBackend.shared.onEvent = { [weak self] name, alarm in self?.
|
|
27
|
+
AlarmKitBackend.shared.onEvent = { [weak self] name, alarm in self?.handle(name, alarm) }
|
|
27
28
|
AlarmKitBackend.shared.startObserving()
|
|
28
29
|
}
|
|
29
30
|
#endif
|
|
31
|
+
// App launched by tapping the alarm notification/alert before this module existed.
|
|
32
|
+
DispatchQueue.main.async { [weak self] in self?.presentIfRinging() }
|
|
33
|
+
// Tapping an AlarmKit alert (and some notification taps on a warm start) only
|
|
34
|
+
// foregrounds the app without any delegate callback, so also check on activation.
|
|
35
|
+
activeObserver = NotificationCenter.default.addObserver(
|
|
36
|
+
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
|
|
37
|
+
) { [weak self] _ in self?.presentIfRinging() }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
deinit {
|
|
41
|
+
if let activeObserver { NotificationCenter.default.removeObserver(activeObserver) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Shows the ring screen when a backend says an alarm is ringing right now.
|
|
45
|
+
private func presentIfRinging() {
|
|
46
|
+
var ringing = NotificationBackend.shared.ringing
|
|
47
|
+
#if canImport(AlarmKit)
|
|
48
|
+
if #available(iOS 26.0, *), ringing == nil { ringing = AlarmKitBackend.shared.ringingAlarm() }
|
|
49
|
+
#endif
|
|
50
|
+
if let ringing { AlarmRingPresenter.present(ringing, bridge: bridge) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func handle(_ name: String, _ alarm: AlarmData) {
|
|
54
|
+
if name == Self.eventFired {
|
|
55
|
+
AlarmRingPresenter.present(alarm, bridge: bridge)
|
|
56
|
+
} else {
|
|
57
|
+
AlarmRingPresenter.dismiss(alarm.id)
|
|
58
|
+
}
|
|
59
|
+
emit(name, alarm)
|
|
30
60
|
}
|
|
31
61
|
|
|
32
62
|
@objc override static func requiresMainQueueSetup() -> Bool { false }
|
|
@@ -61,6 +91,7 @@ final class AlarmModule: RCTEventEmitter {
|
|
|
61
91
|
}
|
|
62
92
|
|
|
63
93
|
private func cancelNative(_ id: String) {
|
|
94
|
+
AlarmRingPresenter.dismiss(id)
|
|
64
95
|
NotificationBackend.shared.cancel(id)
|
|
65
96
|
#if canImport(AlarmKit)
|
|
66
97
|
if #available(iOS 26.0, *) { AlarmKitBackend.shared.cancel(id) }
|
|
@@ -207,6 +238,7 @@ final class AlarmModule: RCTEventEmitter {
|
|
|
207
238
|
|
|
208
239
|
@objc(cancelAllAlarms:reject:)
|
|
209
240
|
func cancelAllAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
241
|
+
AlarmRingPresenter.dismiss()
|
|
210
242
|
AlarmStore.all().forEach { cancelNative($0.id) }
|
|
211
243
|
NotificationBackend.shared.cancelAll()
|
|
212
244
|
#if canImport(AlarmKit)
|
|
@@ -220,6 +252,7 @@ final class AlarmModule: RCTEventEmitter {
|
|
|
220
252
|
|
|
221
253
|
@objc(stopAlarm:resolve:reject:)
|
|
222
254
|
func stopAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
255
|
+
AlarmRingPresenter.dismiss(alarmId)
|
|
223
256
|
NotificationBackend.shared.stop(alarmId)
|
|
224
257
|
#if canImport(AlarmKit)
|
|
225
258
|
if #available(iOS 26.0, *) { AlarmKitBackend.shared.stop(alarmId) }
|
|
@@ -233,6 +266,7 @@ final class AlarmModule: RCTEventEmitter {
|
|
|
233
266
|
return reject("E_NOT_FOUND", "Alarm \(alarmId) not found", nil)
|
|
234
267
|
}
|
|
235
268
|
let mins = minutes > 0 ? Int(minutes) : alarm.snoozeMinutes
|
|
269
|
+
AlarmRingPresenter.dismiss(alarmId)
|
|
236
270
|
|
|
237
271
|
#if canImport(AlarmKit)
|
|
238
272
|
if #available(iOS 26.0, *), useAlarmKit {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import React
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// iOS counterpart of Android's `AlarmRingActivity`: presents the React component
|
|
5
|
+
/// registered as `AlarmRing` full screen, with the ringing alarm as initial props.
|
|
6
|
+
///
|
|
7
|
+
/// iOS cannot launch an app or draw over the lock screen from a local notification,
|
|
8
|
+
/// so this shows when the alarm fires while the app is in the foreground, or when the
|
|
9
|
+
/// user opens the app by tapping the alarm notification.
|
|
10
|
+
final class AlarmRingPresenter {
|
|
11
|
+
static let componentName = "AlarmRing"
|
|
12
|
+
|
|
13
|
+
private static weak var controller: UIViewController?
|
|
14
|
+
private static var alarmId: String?
|
|
15
|
+
|
|
16
|
+
static func present(_ alarm: AlarmData, bridge: RCTBridge?, attempt: Int = 0) {
|
|
17
|
+
DispatchQueue.main.async {
|
|
18
|
+
if controller != nil, alarmId == alarm.id {
|
|
19
|
+
// Already showing (e.g. presented while backgrounded, now foregrounded):
|
|
20
|
+
// still make sure the native sound yields to the screen's own TTS.
|
|
21
|
+
silenceIfActive(alarm.id)
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
// Cold start from a notification tap or the AlarmKit "Open" button: the root
|
|
25
|
+
// surface or window may not be ready yet, so retry for up to ~15 s.
|
|
26
|
+
guard let surface = makeSurface(bridge, props: alarm.toDictionary()),
|
|
27
|
+
let top = topViewController() else {
|
|
28
|
+
if attempt < 60 {
|
|
29
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
|
30
|
+
present(alarm, bridge: bridge, attempt: attempt + 1)
|
|
31
|
+
}
|
|
32
|
+
} else {
|
|
33
|
+
NSLog("[react-native-alarms] Could not present %@: no surface presenter or window", componentName)
|
|
34
|
+
}
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let vc = UIViewController()
|
|
39
|
+
let root = RCTSurfaceHostingProxyRootView(surface: surface)
|
|
40
|
+
root.backgroundColor = .systemBackground
|
|
41
|
+
vc.view = root
|
|
42
|
+
surface.start()
|
|
43
|
+
vc.modalPresentationStyle = .fullScreen
|
|
44
|
+
vc.isModalInPresentation = true
|
|
45
|
+
|
|
46
|
+
let show = {
|
|
47
|
+
top.present(vc, animated: true)
|
|
48
|
+
controller = vc
|
|
49
|
+
alarmId = alarm.id
|
|
50
|
+
silenceIfActive(alarm.id)
|
|
51
|
+
}
|
|
52
|
+
if let existing = controller {
|
|
53
|
+
existing.dismiss(animated: false, completion: show)
|
|
54
|
+
} else {
|
|
55
|
+
show()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// The ring screen speaks the task via TTS, and iOS won't play it over the
|
|
61
|
+
/// notification / AlarmKit sound, so stop that sound once the screen is up.
|
|
62
|
+
/// Only while the app is frontmost: in the background the system alert is the
|
|
63
|
+
/// only thing the user can hear, so it must keep ringing.
|
|
64
|
+
private static func silenceIfActive(_ id: String) {
|
|
65
|
+
guard UIApplication.shared.applicationState == .active else { return }
|
|
66
|
+
NotificationBackend.shared.silence(id)
|
|
67
|
+
#if canImport(AlarmKit)
|
|
68
|
+
if #available(iOS 26.0, *) { AlarmKitBackend.shared.silence(id) }
|
|
69
|
+
#endif
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Called when ringing ends for any reason (stop, snooze, cancel).
|
|
73
|
+
static func dismiss(_ id: String? = nil) {
|
|
74
|
+
DispatchQueue.main.async {
|
|
75
|
+
guard let vc = controller, id == nil || id == alarmId else { return }
|
|
76
|
+
controller = nil
|
|
77
|
+
alarmId = nil
|
|
78
|
+
vc.dismiss(animated: true)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private static let createSurface = NSSelectorFromString("createFabricSurfaceForModuleName:initialProperties:")
|
|
83
|
+
|
|
84
|
+
/// `RCTSurfacePresenter` implements `createFabricSurfaceForModuleName:initialProperties:`
|
|
85
|
+
/// without formally adopting `RCTSurfacePresenterStub`, and its header is ObjC++ (not
|
|
86
|
+
/// importable from Swift), so call it by selector.
|
|
87
|
+
private static func makeSurface(_ bridge: RCTBridge?, props: [String: Any]) -> RCTSurfaceProtocol? {
|
|
88
|
+
guard let presenter = surfacePresenter(bridge), presenter.responds(to: createSurface) else { return nil }
|
|
89
|
+
return presenter.perform(createSurface, with: componentName, with: props)?
|
|
90
|
+
.takeUnretainedValue() as? RCTSurfaceProtocol
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// Bridgeless mode skips `bridge.surfacePresenter` (and injection) for modules
|
|
94
|
+
/// registered with `RCT_EXTERN_MODULE`, so fall back to the presenter of the app's
|
|
95
|
+
/// root React surface, which `RCTFabricSurface` keeps in a weak `_surfacePresenter` ivar.
|
|
96
|
+
private static func surfacePresenter(_ bridge: RCTBridge?) -> NSObject? {
|
|
97
|
+
if let p = bridge?.surfacePresenter() as? NSObject { return p }
|
|
98
|
+
guard let root = keyWindow()?.rootViewController?.view,
|
|
99
|
+
let hosting = findHostingView(in: root),
|
|
100
|
+
let surface = hosting.surface as AnyObject?,
|
|
101
|
+
let ivar = class_getInstanceVariable(type(of: surface), "_surfacePresenter")
|
|
102
|
+
else { return nil }
|
|
103
|
+
return object_getIvar(surface, ivar) as? NSObject
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private static func findHostingView(in view: UIView) -> RCTSurfaceHostingView? {
|
|
107
|
+
if let hosting = view as? RCTSurfaceHostingView { return hosting }
|
|
108
|
+
for sub in view.subviews {
|
|
109
|
+
if let hosting = findHostingView(in: sub) { return hosting }
|
|
110
|
+
}
|
|
111
|
+
return nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private static func keyWindow() -> UIWindow? {
|
|
115
|
+
UIApplication.shared.connectedScenes
|
|
116
|
+
.compactMap { $0 as? UIWindowScene }
|
|
117
|
+
.flatMap { $0.windows }
|
|
118
|
+
.first { $0.isKeyWindow }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private static func topViewController() -> UIViewController? {
|
|
122
|
+
var top = keyWindow()?.rootViewController
|
|
123
|
+
while let presented = top?.presentedViewController { top = presented }
|
|
124
|
+
return top
|
|
125
|
+
}
|
|
126
|
+
}
|