react-native-alarms 0.0.0-stage → 0.0.2

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.
Files changed (64) hide show
  1. package/.bundle/config +2 -0
  2. package/AlarmService.d.ts +112 -0
  3. package/AlarmService.ts +196 -0
  4. package/README.md +385 -2
  5. package/android/app/build.gradle +119 -0
  6. package/android/app/debug.keystore +0 -0
  7. package/android/app/proguard-rules.pro +10 -0
  8. package/android/app/src/main/AndroidManifest.xml +81 -0
  9. package/android/app/src/main/java/com/reactnativealarm/MainActivity.kt +29 -0
  10. package/android/app/src/main/java/com/reactnativealarm/MainApplication.kt +29 -0
  11. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmData.kt +134 -0
  12. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmModule.kt +263 -0
  13. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmPackage.kt +24 -0
  14. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmReceiver.kt +43 -0
  15. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmRingActivity.kt +86 -0
  16. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmScheduler.kt +74 -0
  17. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmService.kt +285 -0
  18. package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmStore.kt +13 -0
  19. package/android/app/src/main/java/com/reactnativealarm/alarm/BootReceiver.kt +29 -0
  20. package/android/app/src/main/res/drawable/rn_edit_text_material.xml +37 -0
  21. package/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
  22. package/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
  23. package/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
  24. package/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
  25. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
  26. package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
  27. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
  28. package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
  29. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
  30. package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
  31. package/android/app/src/main/res/values/strings.xml +3 -0
  32. package/android/app/src/main/res/values/styles.xml +9 -0
  33. package/android/build.gradle +21 -0
  34. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  35. package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
  36. package/android/gradle.properties +49 -0
  37. package/android/gradlew +248 -0
  38. package/android/gradlew.bat +98 -0
  39. package/android/settings.gradle +6 -0
  40. package/app.json +4 -0
  41. package/index.d.ts +20 -0
  42. package/index.js +12 -0
  43. package/index.ts +21 -0
  44. package/ios/.xcode.env +11 -0
  45. package/ios/Podfile +34 -0
  46. package/ios/Podfile.lock +2179 -0
  47. package/ios/ReactNativeAlarm/AppDelegate.swift +14 -0
  48. package/ios/ReactNativeAlarm/Images.xcassets/AppIcon.appiconset/Contents.json +53 -0
  49. package/ios/ReactNativeAlarm/Images.xcassets/Contents.json +6 -0
  50. package/ios/ReactNativeAlarm/Info.plist +79 -0
  51. package/ios/ReactNativeAlarm/LaunchScreen.storyboard +47 -0
  52. package/ios/ReactNativeAlarm/PrivacyInfo.xcprivacy +37 -0
  53. package/ios/ReactNativeAlarm/SceneDelegate.swift +56 -0
  54. package/ios/ReactNativeAlarm.xcodeproj/project.pbxproj +530 -0
  55. package/ios/ReactNativeAlarm.xcodeproj/xcshareddata/xcschemes/ReactNativeAlarm.xcscheme +88 -0
  56. package/ios/ReactNativeAlarm.xcworkspace/contents.xcworkspacedata +10 -0
  57. package/ios/alarm/AlarmData.swift +97 -0
  58. package/ios/alarm/AlarmKitBackend.swift +212 -0
  59. package/ios/alarm/AlarmModule.m +53 -0
  60. package/ios/alarm/AlarmModule.swift +284 -0
  61. package/ios/alarm/AlarmStore.swift +51 -0
  62. package/ios/alarm/NotificationBackend.swift +227 -0
  63. package/package.json +91 -3
  64. package/specs/NativeAlarm.ts +95 -0
@@ -0,0 +1,97 @@
1
+ import Foundation
2
+
3
+ /// iOS mirror of the Android `AlarmData` model: same fields, same JS shape
4
+ /// (`toDictionary()` == Android `toWritableMap()`), same `nextTrigger` rules.
5
+ struct AlarmData: Codable {
6
+ var id: String
7
+ var timestamp: Double // ms since epoch
8
+ var title: String
9
+ var body: String
10
+ var sound: String
11
+ var vibrate: Bool
12
+ var snoozeEnabled: Bool
13
+ var snoozeMinutes: Int
14
+ var repeatType: String // none | daily | weekly | interval
15
+ var weekdays: [Int] // 1 = Sunday ... 7 = Saturday
16
+ var intervalMs: Double
17
+ var data: String
18
+ var enabled: Bool = true
19
+
20
+ static func now() -> Double { Date().timeIntervalSince1970 * 1000 }
21
+
22
+ var date: Date { Date(timeIntervalSince1970: timestamp / 1000) }
23
+
24
+ /// Next time this alarm should ring, in ms since epoch, or 0 if never.
25
+ func nextTrigger(from: Double = AlarmData.now()) -> Double {
26
+ if !enabled { return 0 }
27
+ switch repeatType {
28
+ case "daily":
29
+ return nextMatching(from: from) { _ in true }
30
+ case "weekly":
31
+ return nextMatching(from: from) { weekdays.isEmpty || weekdays.contains($0) }
32
+ case "interval":
33
+ if intervalMs <= 0 { return 0 }
34
+ if timestamp > from { return timestamp }
35
+ return timestamp + (floor((from - timestamp) / intervalMs) + 1) * intervalMs
36
+ default:
37
+ return timestamp > from ? timestamp : 0
38
+ }
39
+ }
40
+
41
+ private func nextMatching(from: Double, ok: (Int) -> Bool) -> Double {
42
+ let cal = Calendar.current
43
+ let base = cal.dateComponents([.hour, .minute], from: date)
44
+ var day = Date(timeIntervalSince1970: from / 1000)
45
+ for _ in 0..<8 {
46
+ var comps = cal.dateComponents([.year, .month, .day], from: day)
47
+ comps.hour = base.hour
48
+ comps.minute = base.minute
49
+ comps.second = 0
50
+ if let candidate = cal.date(from: comps) {
51
+ let ms = candidate.timeIntervalSince1970 * 1000
52
+ if ms > from && ok(cal.component(.weekday, from: candidate)) { return ms }
53
+ }
54
+ day = cal.date(byAdding: .day, value: 1, to: day) ?? day
55
+ }
56
+ return 0
57
+ }
58
+
59
+ // MARK: - JS bridge shape (same keys as Android)
60
+
61
+ func toDictionary() -> [String: Any] {
62
+ [
63
+ "alarmId": id,
64
+ "timestamp": timestamp,
65
+ "title": title,
66
+ "body": body,
67
+ "sound": sound,
68
+ "vibrate": vibrate,
69
+ "snoozeEnabled": snoozeEnabled,
70
+ "snoozeMinutes": snoozeMinutes,
71
+ "repeat": ["type": repeatType, "weekdays": weekdays, "intervalMs": intervalMs],
72
+ "data": data,
73
+ "enabled": enabled,
74
+ "nextTriggerAt": nextTrigger(),
75
+ ]
76
+ }
77
+
78
+ /// Parses the `AlarmConfig` object JS passes to `scheduleAlarm` / `updateAlarm`.
79
+ static func from(config m: [String: Any], id: String) -> AlarmData {
80
+ let r = m["repeat"] as? [String: Any]
81
+ return AlarmData(
82
+ id: id,
83
+ timestamp: (m["timestamp"] as? NSNumber)?.doubleValue ?? 0,
84
+ title: m["title"] as? String ?? "",
85
+ body: m["body"] as? String ?? "",
86
+ sound: m["sound"] as? String ?? "default",
87
+ vibrate: (m["vibrate"] as? NSNumber)?.boolValue ?? true,
88
+ snoozeEnabled: (m["snoozeEnabled"] as? NSNumber)?.boolValue ?? true,
89
+ snoozeMinutes: (m["snoozeMinutes"] as? NSNumber)?.intValue ?? 5,
90
+ repeatType: r?["type"] as? String ?? "none",
91
+ weekdays: (r?["weekdays"] as? [NSNumber])?.map { $0.intValue } ?? [],
92
+ intervalMs: (r?["intervalMs"] as? NSNumber)?.doubleValue ?? 0,
93
+ data: m["data"] as? String ?? "",
94
+ enabled: true
95
+ )
96
+ }
97
+ }
@@ -0,0 +1,212 @@
1
+ import CryptoKit
2
+ import Foundation
3
+ import SwiftUI
4
+
5
+ #if canImport(AlarmKit)
6
+ import ActivityKit
7
+ import AlarmKit
8
+
9
+ /// Metadata type required by `AlarmAttributes`; extend it if you add a Live Activity widget.
10
+ @available(iOS 26.0, *)
11
+ struct RNAlarmMetadata: AlarmMetadata {}
12
+
13
+ /// Real alarms on iOS 26+: ring through silent mode and Focus with the system alarm UI.
14
+ /// Only compiled with an SDK that ships AlarmKit (Xcode 26+); older toolchains build
15
+ /// the app with the notification fallback only.
16
+ @available(iOS 26.0, *)
17
+ final class AlarmKitBackend {
18
+ static let shared = AlarmKitBackend()
19
+
20
+ private let manager = AlarmManager.shared
21
+ /// Wired by `AlarmModule` so native events reach JS.
22
+ var onEvent: ((String, AlarmData) -> Void)?
23
+
24
+ private var observer: Task<Void, Never>?
25
+ private var lastStates: [UUID: Alarm.State] = [:]
26
+ /// Alarms we are stopping as part of a programmatic snooze (so the observer
27
+ /// reports "snoozed" instead of "dismissed").
28
+ private var snoozing: Set<UUID> = []
29
+
30
+ private static let allWeekdays: [Locale.Weekday] = [
31
+ .sunday, .monday, .tuesday, .wednesday, .thursday, .friday, .saturday,
32
+ ]
33
+
34
+ // MARK: - Authorization
35
+
36
+ var isAuthorized: Bool { manager.authorizationState == .authorized }
37
+
38
+ /// 'granted' | 'denied' | 'notDetermined', same vocabulary as Android.
39
+ var status: String {
40
+ switch manager.authorizationState {
41
+ case .authorized: return "granted"
42
+ case .denied: return "denied"
43
+ default: return "notDetermined"
44
+ }
45
+ }
46
+
47
+ func requestAuthorization() async -> Bool {
48
+ (try? await manager.requestAuthorization()) == .authorized
49
+ }
50
+
51
+ // MARK: - Ids
52
+
53
+ /// Android ids are free-form strings; AlarmKit wants UUIDs. Real UUIDs pass through,
54
+ /// anything else is hashed deterministically so cancel/stop can find it again.
55
+ static func uuid(for id: String, snooze: Bool = false) -> UUID {
56
+ let key = snooze ? id + "_s" : id
57
+ if !snooze, let u = UUID(uuidString: key) { return u }
58
+ let bytes = Array(SHA256.hash(data: Data(key.utf8)).prefix(16))
59
+ return bytes.withUnsafeBufferPointer { NSUUID(uuidBytes: $0.baseAddress!) as UUID }
60
+ }
61
+
62
+ private func stored(for uuid: UUID) -> AlarmData? {
63
+ AlarmStore.all().first {
64
+ Self.uuid(for: $0.id) == uuid || Self.uuid(for: $0.id, snooze: true) == uuid
65
+ }
66
+ }
67
+
68
+ // MARK: - Scheduling
69
+
70
+ func schedule(_ a: AlarmData) async throws {
71
+ cancel(a.id)
72
+ let next = a.nextTrigger()
73
+ guard next > 0 else { return } // nothing in the future; Android skips too
74
+
75
+ let hm = Calendar.current.dateComponents([.hour, .minute], from: a.date)
76
+ let time = Alarm.Schedule.Relative.Time(hour: hm.hour ?? 0, minute: hm.minute ?? 0)
77
+ let schedule: Alarm.Schedule
78
+ switch a.repeatType {
79
+ case "daily":
80
+ schedule = .relative(.init(time: time, repeats: .weekly(Self.allWeekdays)))
81
+ case "weekly":
82
+ let days = a.weekdays.isEmpty
83
+ ? Self.allWeekdays
84
+ : a.weekdays.filter { (1...7).contains($0) }.map { Self.allWeekdays[$0 - 1] }
85
+ schedule = .relative(.init(time: time, repeats: .weekly(days)))
86
+ default:
87
+ // none: one exact ring. interval: one exact ring, re-armed by the observer
88
+ // after it is dismissed (only while the app process is alive).
89
+ schedule = .fixed(Date(timeIntervalSince1970: next / 1000))
90
+ }
91
+
92
+ _ = try await manager.schedule(id: Self.uuid(for: a.id), configuration: configuration(for: a, schedule: schedule))
93
+ startObserving()
94
+ }
95
+
96
+ private func configuration(for a: AlarmData, schedule: Alarm.Schedule)
97
+ -> AlarmManager.AlarmConfiguration<RNAlarmMetadata>
98
+ {
99
+ let title = LocalizedStringResource(stringLiteral: a.title.isEmpty ? "Alarm" : a.title)
100
+ let stop = AlarmButton(text: "Stop", textColor: .white, systemImageName: "stop.fill")
101
+
102
+ let alert: AlarmPresentation.Alert
103
+ var countdown: AlarmPresentation.Countdown?
104
+ var duration: Alarm.CountdownDuration?
105
+ if a.snoozeEnabled {
106
+ let snooze = AlarmButton(text: "Snooze", textColor: .white, systemImageName: "zzz")
107
+ alert = AlarmPresentation.Alert(
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
+ }
115
+
116
+ let attributes = AlarmAttributes<RNAlarmMetadata>(
117
+ presentation: AlarmPresentation(alert: alert, countdown: countdown),
118
+ metadata: RNAlarmMetadata(),
119
+ tintColor: .blue
120
+ )
121
+ let sound: AlertConfiguration.AlertSound =
122
+ (a.sound.isEmpty || a.sound == "default") ? .default : .named(a.sound)
123
+
124
+ return AlarmManager.AlarmConfiguration(
125
+ countdownDuration: duration,
126
+ schedule: schedule,
127
+ attributes: attributes,
128
+ sound: sound
129
+ )
130
+ }
131
+
132
+ func cancel(_ id: String) {
133
+ try? manager.cancel(id: Self.uuid(for: id))
134
+ try? manager.cancel(id: Self.uuid(for: id, snooze: true))
135
+ }
136
+
137
+ func cancelAll() {
138
+ for alarm in (try? manager.alarms) ?? [] { try? manager.cancel(id: alarm.id) }
139
+ }
140
+
141
+ func stop(_ id: String) {
142
+ try? manager.stop(id: Self.uuid(for: id))
143
+ try? manager.stop(id: Self.uuid(for: id, snooze: true))
144
+ }
145
+
146
+ /// Silences the ringing alarm (if any) and re-rings once in `minutes`,
147
+ /// equivalent to Android's `AlarmScheduler.scheduleSnooze`.
148
+ func snooze(_ a: AlarmData, minutes: Int) async throws {
149
+ let current = (try? manager.alarms) ?? []
150
+ for uuid in [Self.uuid(for: a.id), Self.uuid(for: a.id, snooze: true)]
151
+ where current.contains(where: { $0.id == uuid && $0.state == .alerting }) {
152
+ snoozing.insert(uuid)
153
+ try? manager.stop(id: uuid)
154
+ }
155
+ let date = Date().addingTimeInterval(Double(max(1, minutes)) * 60)
156
+ _ = try await manager.schedule(
157
+ id: Self.uuid(for: a.id, snooze: true),
158
+ configuration: configuration(for: a, schedule: .fixed(date))
159
+ )
160
+ startObserving()
161
+ }
162
+
163
+ func ringingAlarm() -> AlarmData? {
164
+ guard let alerting = ((try? manager.alarms) ?? []).first(where: { $0.state == .alerting }) else {
165
+ return nil
166
+ }
167
+ return stored(for: alerting.id)
168
+ }
169
+
170
+ // MARK: - Events
171
+
172
+ /// Translates AlarmKit state changes into the Android-style events.
173
+ func startObserving() {
174
+ guard observer == nil else { return }
175
+ for alarm in (try? manager.alarms) ?? [] { lastStates[alarm.id] = alarm.state }
176
+ observer = Task { [weak self] in
177
+ for await alarms in AlarmManager.shared.alarmUpdates {
178
+ self?.handle(alarms)
179
+ }
180
+ }
181
+ }
182
+
183
+ private func handle(_ alarms: [Alarm]) {
184
+ var states: [UUID: Alarm.State] = [:]
185
+ for alarm in alarms { states[alarm.id] = alarm.state }
186
+
187
+ for (uuid, state) in states where lastStates[uuid] != state {
188
+ guard let a = stored(for: uuid) else { continue }
189
+ if state == .alerting {
190
+ onEvent?(AlarmModule.eventFired, a)
191
+ } else if lastStates[uuid] == .alerting {
192
+ onEvent?(state == .countdown ? AlarmModule.eventSnoozed : AlarmModule.eventDismissed, a)
193
+ }
194
+ }
195
+
196
+ // One-off (`.fixed`) alarms disappear once stopped.
197
+ for (uuid, previous) in lastStates where states[uuid] == nil && previous == .alerting {
198
+ guard let a = stored(for: uuid) else { continue }
199
+ if snoozing.remove(uuid) != nil {
200
+ onEvent?(AlarmModule.eventSnoozed, a)
201
+ } else {
202
+ onEvent?(AlarmModule.eventDismissed, a)
203
+ if a.repeatType == "interval" {
204
+ Task { try? await self.schedule(a) }
205
+ }
206
+ }
207
+ }
208
+
209
+ lastStates = states
210
+ }
211
+ }
212
+ #endif
@@ -0,0 +1,53 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
3
+
4
+ // Exposes the Swift `AlarmModule` to JS as `NativeModules.Alarm`, the same name
5
+ // as Android's `AlarmModule.NAME`. Method names and signatures match Android.
6
+ @interface RCT_EXTERN_REMAP_MODULE(Alarm, AlarmModule, RCTEventEmitter)
7
+
8
+ // ---------- Permissions ----------
9
+ RCT_EXTERN_METHOD(checkPermission:(RCTPromiseResolveBlock)resolve
10
+ reject:(RCTPromiseRejectBlock)reject)
11
+ RCT_EXTERN_METHOD(requestPermission:(RCTPromiseResolveBlock)resolve
12
+ reject:(RCTPromiseRejectBlock)reject)
13
+ RCT_EXTERN_METHOD(canScheduleExactAlarms:(RCTPromiseResolveBlock)resolve
14
+ reject:(RCTPromiseRejectBlock)reject)
15
+ RCT_EXTERN_METHOD(openAutoStartSettings:(RCTPromiseResolveBlock)resolve
16
+ reject:(RCTPromiseRejectBlock)reject)
17
+
18
+ // ---------- Scheduling ----------
19
+ RCT_EXTERN_METHOD(scheduleAlarm:(NSDictionary *)config
20
+ resolve:(RCTPromiseResolveBlock)resolve
21
+ reject:(RCTPromiseRejectBlock)reject)
22
+ RCT_EXTERN_METHOD(updateAlarm:(NSString *)alarmId
23
+ config:(NSDictionary *)config
24
+ resolve:(RCTPromiseResolveBlock)resolve
25
+ reject:(RCTPromiseRejectBlock)reject)
26
+ RCT_EXTERN_METHOD(cancelAlarm:(NSString *)alarmId
27
+ resolve:(RCTPromiseResolveBlock)resolve
28
+ reject:(RCTPromiseRejectBlock)reject)
29
+ RCT_EXTERN_METHOD(cancelAllAlarms:(RCTPromiseResolveBlock)resolve
30
+ reject:(RCTPromiseRejectBlock)reject)
31
+
32
+ // ---------- Control (while ringing) ----------
33
+ RCT_EXTERN_METHOD(stopAlarm:(NSString *)alarmId
34
+ resolve:(RCTPromiseResolveBlock)resolve
35
+ reject:(RCTPromiseRejectBlock)reject)
36
+ RCT_EXTERN_METHOD(snoozeAlarm:(NSString *)alarmId
37
+ minutes:(double)minutes
38
+ resolve:(RCTPromiseResolveBlock)resolve
39
+ reject:(RCTPromiseRejectBlock)reject)
40
+ RCT_EXTERN_METHOD(getRingingAlarm:(RCTPromiseResolveBlock)resolve
41
+ reject:(RCTPromiseRejectBlock)reject)
42
+
43
+ // ---------- Queries ----------
44
+ RCT_EXTERN_METHOD(getAlarm:(NSString *)alarmId
45
+ resolve:(RCTPromiseResolveBlock)resolve
46
+ reject:(RCTPromiseRejectBlock)reject)
47
+ RCT_EXTERN_METHOD(getAllAlarms:(RCTPromiseResolveBlock)resolve
48
+ reject:(RCTPromiseRejectBlock)reject)
49
+ RCT_EXTERN_METHOD(isScheduled:(NSString *)alarmId
50
+ resolve:(RCTPromiseResolveBlock)resolve
51
+ reject:(RCTPromiseRejectBlock)reject)
52
+
53
+ @end
@@ -0,0 +1,284 @@
1
+ import Foundation
2
+ import React
3
+ import UIKit
4
+ import UserNotifications
5
+
6
+ /// iOS counterpart of the Android `AlarmModule`: same JS name (`Alarm`), same method
7
+ /// names, same payload shapes and the same `onAlarmFired` / `onAlarmDismissed` /
8
+ /// `onAlarmSnoozed` events, so JS code is shared between the platforms.
9
+ ///
10
+ /// Backend: AlarmKit on iOS 26+ once the user grants alarm access (real alarms that
11
+ /// ring through silent mode); local notifications everywhere else.
12
+ @objc(AlarmModule)
13
+ final class AlarmModule: RCTEventEmitter {
14
+ static let eventFired = "onAlarmFired"
15
+ static let eventDismissed = "onAlarmDismissed"
16
+ static let eventSnoozed = "onAlarmSnoozed"
17
+
18
+ private var hasListeners = false
19
+
20
+ override init() {
21
+ super.init()
22
+ NotificationBackend.install()
23
+ NotificationBackend.shared.onEvent = { [weak self] name, alarm in self?.emit(name, alarm) }
24
+ #if canImport(AlarmKit)
25
+ if #available(iOS 26.0, *) {
26
+ AlarmKitBackend.shared.onEvent = { [weak self] name, alarm in self?.emit(name, alarm) }
27
+ AlarmKitBackend.shared.startObserving()
28
+ }
29
+ #endif
30
+ }
31
+
32
+ @objc override static func requiresMainQueueSetup() -> Bool { false }
33
+
34
+ // MARK: - Events
35
+
36
+ override func supportedEvents() -> [String]! {
37
+ [Self.eventFired, Self.eventDismissed, Self.eventSnoozed]
38
+ }
39
+
40
+ override func startObserving() { hasListeners = true }
41
+ override func stopObserving() { hasListeners = false }
42
+
43
+ private func emit(_ name: String, _ a: AlarmData) {
44
+ guard hasListeners else { return }
45
+ sendEvent(withName: name, body: [
46
+ "alarmId": a.id,
47
+ "timestamp": AlarmData.now(),
48
+ "title": a.title,
49
+ "body": a.body,
50
+ "data": a.data,
51
+ ])
52
+ }
53
+
54
+ // MARK: - Backend selection
55
+
56
+ private var useAlarmKit: Bool {
57
+ #if canImport(AlarmKit)
58
+ if #available(iOS 26.0, *) { return AlarmKitBackend.shared.isAuthorized }
59
+ #endif
60
+ return false
61
+ }
62
+
63
+ private func cancelNative(_ id: String) {
64
+ NotificationBackend.shared.cancel(id)
65
+ #if canImport(AlarmKit)
66
+ if #available(iOS 26.0, *) { AlarmKitBackend.shared.cancel(id) }
67
+ #endif
68
+ }
69
+
70
+ // MARK: - Permissions
71
+
72
+ /// Same keys as Android's `permissionMap()`. The Android-only flags are always
73
+ /// true here so shared UI code can treat them as granted.
74
+ private func permissionMap(_ done: @escaping ([String: Any]) -> Void) {
75
+ NotificationBackend.shared.status { notifications in
76
+ var status = notifications
77
+ var backend = "notifications"
78
+ #if canImport(AlarmKit)
79
+ if #available(iOS 26.0, *) {
80
+ status = AlarmKitBackend.shared.status
81
+ backend = "alarmkit"
82
+ }
83
+ #endif
84
+ done([
85
+ "status": status,
86
+ "notifications": status == "granted" || notifications == "granted",
87
+ "canScheduleExact": status == "granted",
88
+ "canDrawOverlays": true,
89
+ "canUseFullScreenIntent": true,
90
+ "backend": backend,
91
+ ])
92
+ }
93
+ }
94
+
95
+ private func notificationStatus() async -> String {
96
+ await withCheckedContinuation { c in NotificationBackend.shared.status { c.resume(returning: $0) } }
97
+ }
98
+
99
+ private func requestNotifications() async {
100
+ _ = await withCheckedContinuation { c in NotificationBackend.shared.request { c.resume(returning: $0) } }
101
+ }
102
+
103
+ /// iOS never re-prompts after a denial, so (like Android for its special
104
+ /// permissions) send the user to the app's Settings page.
105
+ @MainActor private func openSettings() {
106
+ if let url = URL(string: UIApplication.openSettingsURLString) {
107
+ UIApplication.shared.open(url)
108
+ }
109
+ }
110
+
111
+ @objc(checkPermission:reject:)
112
+ func checkPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
113
+ permissionMap { resolve($0) }
114
+ }
115
+
116
+ @objc(requestPermission:reject:)
117
+ func requestPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
118
+ Task {
119
+ #if canImport(AlarmKit)
120
+ if #available(iOS 26.0, *) {
121
+ let kit = AlarmKitBackend.shared
122
+ if kit.status == "notDetermined" { _ = await kit.requestAuthorization() }
123
+ if kit.isAuthorized {
124
+ self.permissionMap { resolve($0) }
125
+ return
126
+ }
127
+ await self.openSettings()
128
+ }
129
+ #endif
130
+ // Notifications back the fallback path.
131
+ let notifications = await self.notificationStatus()
132
+ if notifications == "notDetermined" {
133
+ await self.requestNotifications()
134
+ } else if notifications == "denied" {
135
+ await self.openSettings()
136
+ }
137
+ self.permissionMap { resolve($0) }
138
+ }
139
+ }
140
+
141
+ @objc(canScheduleExactAlarms:reject:)
142
+ func canScheduleExactAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
143
+ permissionMap { (map: [String: Any]) in
144
+ resolve((map["canScheduleExact"] as? Bool) ?? false)
145
+ }
146
+ }
147
+
148
+ /// Android OEM concept; iOS has no such screen.
149
+ @objc(openAutoStartSettings:reject:)
150
+ func openAutoStartSettings(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
151
+ resolve(false)
152
+ }
153
+
154
+ // MARK: - Scheduling
155
+
156
+ @objc(scheduleAlarm:resolve:reject:)
157
+ func scheduleAlarm(_ config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
158
+ let dict = config as? [String: Any] ?? [:]
159
+ let id = (dict["alarmId"] as? String) ?? UUID().uuidString
160
+ arm(AlarmData.from(config: dict, id: id), code: "E_SCHEDULE", reject: reject) { resolve(id) }
161
+ }
162
+
163
+ @objc(updateAlarm:config:resolve:reject:)
164
+ func updateAlarm(_ alarmId: String, config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
165
+ guard AlarmStore.get(alarmId) != nil else { return resolve(false) }
166
+ let dict = config as? [String: Any] ?? [:]
167
+ arm(AlarmData.from(config: dict, id: alarmId), code: "E_UPDATE", reject: reject) { resolve(true) }
168
+ }
169
+
170
+ /// Cancel → persist → schedule, the same sequence as Android.
171
+ private func arm(_ alarm: AlarmData, code: String, reject: @escaping RCTPromiseRejectBlock, done: @escaping () -> Void) {
172
+ cancelNative(alarm.id)
173
+ AlarmStore.save(alarm)
174
+
175
+ #if canImport(AlarmKit)
176
+ if #available(iOS 26.0, *), useAlarmKit {
177
+ Task {
178
+ do {
179
+ try await AlarmKitBackend.shared.schedule(alarm)
180
+ done()
181
+ } catch {
182
+ reject(code, error.localizedDescription, error)
183
+ }
184
+ }
185
+ return
186
+ }
187
+ #endif
188
+
189
+ NotificationBackend.shared.status { status in
190
+ guard status == "granted" else {
191
+ // Mirrors Android's SecurityException when the exact-alarm permission is missing.
192
+ return reject(code, "Alarm permission not granted; call requestPermission() first", nil)
193
+ }
194
+ NotificationBackend.shared.schedule(alarm) { error in
195
+ if let error { reject(code, error.localizedDescription, error) } else { done() }
196
+ }
197
+ }
198
+ }
199
+
200
+ @objc(cancelAlarm:resolve:reject:)
201
+ func cancelAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
202
+ let existed = AlarmStore.get(alarmId) != nil
203
+ cancelNative(alarmId)
204
+ AlarmStore.remove(alarmId)
205
+ resolve(existed)
206
+ }
207
+
208
+ @objc(cancelAllAlarms:reject:)
209
+ func cancelAllAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
210
+ AlarmStore.all().forEach { cancelNative($0.id) }
211
+ NotificationBackend.shared.cancelAll()
212
+ #if canImport(AlarmKit)
213
+ if #available(iOS 26.0, *) { AlarmKitBackend.shared.cancelAll() }
214
+ #endif
215
+ AlarmStore.clear()
216
+ resolve(nil)
217
+ }
218
+
219
+ // MARK: - Control (while ringing)
220
+
221
+ @objc(stopAlarm:resolve:reject:)
222
+ func stopAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
223
+ NotificationBackend.shared.stop(alarmId)
224
+ #if canImport(AlarmKit)
225
+ if #available(iOS 26.0, *) { AlarmKitBackend.shared.stop(alarmId) }
226
+ #endif
227
+ resolve(nil)
228
+ }
229
+
230
+ @objc(snoozeAlarm:minutes:resolve:reject:)
231
+ func snoozeAlarm(_ alarmId: String, minutes: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
232
+ guard let alarm = AlarmStore.get(alarmId) else {
233
+ return reject("E_NOT_FOUND", "Alarm \(alarmId) not found", nil)
234
+ }
235
+ let mins = minutes > 0 ? Int(minutes) : alarm.snoozeMinutes
236
+
237
+ #if canImport(AlarmKit)
238
+ if #available(iOS 26.0, *), useAlarmKit {
239
+ Task {
240
+ do {
241
+ try await AlarmKitBackend.shared.snooze(alarm, minutes: mins)
242
+ resolve(nil)
243
+ } catch {
244
+ reject("E_SNOOZE", error.localizedDescription, error)
245
+ }
246
+ }
247
+ return
248
+ }
249
+ #endif
250
+
251
+ let wasRinging = NotificationBackend.shared.ringing?.id == alarmId
252
+ NotificationBackend.shared.snooze(alarm, minutes: mins)
253
+ if wasRinging { emit(Self.eventSnoozed, alarm) }
254
+ resolve(nil)
255
+ }
256
+
257
+ /// The alarm currently ringing (if any), so JS can show the ring UI on launch.
258
+ @objc(getRingingAlarm:reject:)
259
+ func getRingingAlarm(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
260
+ var ringing = NotificationBackend.shared.ringing
261
+ #if canImport(AlarmKit)
262
+ if #available(iOS 26.0, *), ringing == nil { ringing = AlarmKitBackend.shared.ringingAlarm() }
263
+ #endif
264
+ resolve(ringing?.toDictionary())
265
+ }
266
+
267
+ // MARK: - Queries
268
+
269
+ @objc(getAlarm:resolve:reject:)
270
+ func getAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
271
+ resolve(AlarmStore.get(alarmId)?.toDictionary())
272
+ }
273
+
274
+ @objc(getAllAlarms:reject:)
275
+ func getAllAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
276
+ resolve(AlarmStore.all().sorted { $0.timestamp < $1.timestamp }.map { $0.toDictionary() })
277
+ }
278
+
279
+ @objc(isScheduled:resolve:reject:)
280
+ func isScheduled(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
281
+ let a = AlarmStore.get(alarmId)
282
+ resolve(a != nil && a!.enabled && a!.nextTrigger() > 0)
283
+ }
284
+ }
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+
3
+ /// UserDefaults-backed persistence, the iOS counterpart of Android's `AlarmStore`.
4
+ enum AlarmStore {
5
+ private static let key = "RNAlarm.alarms"
6
+ private static let lock = NSLock()
7
+
8
+ static func all() -> [AlarmData] {
9
+ lock.lock(); defer { lock.unlock() }
10
+ return Array(load().values)
11
+ }
12
+
13
+ static func get(_ id: String) -> AlarmData? {
14
+ lock.lock(); defer { lock.unlock() }
15
+ return load()[id]
16
+ }
17
+
18
+ static func save(_ alarm: AlarmData) {
19
+ lock.lock(); defer { lock.unlock() }
20
+ var map = load()
21
+ map[alarm.id] = alarm
22
+ persist(map)
23
+ }
24
+
25
+ static func remove(_ id: String) {
26
+ lock.lock(); defer { lock.unlock() }
27
+ var map = load()
28
+ map.removeValue(forKey: id)
29
+ persist(map)
30
+ }
31
+
32
+ static func clear() {
33
+ lock.lock(); defer { lock.unlock() }
34
+ persist([:])
35
+ }
36
+
37
+ // MARK: - Private
38
+
39
+ private static func load() -> [String: AlarmData] {
40
+ guard let data = UserDefaults.standard.data(forKey: key),
41
+ let map = try? JSONDecoder().decode([String: AlarmData].self, from: data)
42
+ else { return [:] }
43
+ return map
44
+ }
45
+
46
+ private static func persist(_ map: [String: AlarmData]) {
47
+ if let data = try? JSONEncoder().encode(map) {
48
+ UserDefaults.standard.set(data, forKey: key)
49
+ }
50
+ }
51
+ }