react-native-alarms 0.0.5 → 0.0.7

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.
@@ -14,20 +14,20 @@ final class AlarmModule: RCTEventEmitter {
14
14
  static let eventFired = "onAlarmFired"
15
15
  static let eventDismissed = "onAlarmDismissed"
16
16
  static let eventSnoozed = "onAlarmSnoozed"
17
-
17
+
18
18
  private var hasListeners = false
19
19
  private var activeObserver: NSObjectProtocol?
20
-
20
+
21
21
  override init() {
22
22
  super.init()
23
23
  NotificationBackend.install()
24
24
  NotificationBackend.shared.onEvent = { [weak self] name, alarm in self?.handle(name, alarm) }
25
- #if canImport(AlarmKit)
25
+ #if canImport(AlarmKit)
26
26
  if #available(iOS 26.0, *) {
27
27
  AlarmKitBackend.shared.onEvent = { [weak self] name, alarm in self?.handle(name, alarm) }
28
28
  AlarmKitBackend.shared.startObserving()
29
29
  }
30
- #endif
30
+ #endif
31
31
  // App launched by tapping the alarm notification/alert before this module existed.
32
32
  DispatchQueue.main.async { [weak self] in self?.presentIfRinging() }
33
33
  // Tapping an AlarmKit alert (and some notification taps on a warm start) only
@@ -36,40 +36,44 @@ final class AlarmModule: RCTEventEmitter {
36
36
  forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
37
37
  ) { [weak self] _ in self?.presentIfRinging() }
38
38
  }
39
-
39
+
40
40
  deinit {
41
41
  if let activeObserver { NotificationCenter.default.removeObserver(activeObserver) }
42
42
  }
43
-
43
+
44
44
  /// Shows the ring screen when a backend says an alarm is ringing right now.
45
45
  private func presentIfRinging() {
46
46
  var ringing = NotificationBackend.shared.ringing
47
- #if canImport(AlarmKit)
47
+ #if canImport(AlarmKit)
48
48
  if #available(iOS 26.0, *), ringing == nil { ringing = AlarmKitBackend.shared.ringingAlarm() }
49
- #endif
50
- if let ringing { AlarmRingPresenter.present(ringing, bridge: bridge) }
49
+ #endif
50
+ if let ringing { AlarmRingPresenter.present(ringing, bridge: bridge,
51
+ componentName: ringing.componentName ?? "AlarmRing",
52
+ componentProps: ringing.componentProps ?? [:]) }
51
53
  }
52
-
54
+
53
55
  private func handle(_ name: String, _ alarm: AlarmData) {
54
56
  if name == Self.eventFired {
55
- AlarmRingPresenter.present(alarm, bridge: bridge)
57
+ AlarmRingPresenter.present(alarm, bridge: bridge,
58
+ componentName: alarm.componentName ?? "AlarmRing",
59
+ componentProps: alarm.componentProps ?? [:])
56
60
  } else {
57
61
  AlarmRingPresenter.dismiss(alarm.id)
58
62
  }
59
63
  emit(name, alarm)
60
64
  }
61
-
65
+
62
66
  @objc override static func requiresMainQueueSetup() -> Bool { false }
63
-
67
+
64
68
  // MARK: - Events
65
-
69
+
66
70
  override func supportedEvents() -> [String]! {
67
71
  [Self.eventFired, Self.eventDismissed, Self.eventSnoozed]
68
72
  }
69
-
73
+
70
74
  override func startObserving() { hasListeners = true }
71
75
  override func stopObserving() { hasListeners = false }
72
-
76
+
73
77
  private func emit(_ name: String, _ a: AlarmData) {
74
78
  guard hasListeners else { return }
75
79
  sendEvent(withName: name, body: [
@@ -80,38 +84,38 @@ final class AlarmModule: RCTEventEmitter {
80
84
  "data": a.data,
81
85
  ])
82
86
  }
83
-
87
+
84
88
  // MARK: - Backend selection
85
-
89
+
86
90
  private var useAlarmKit: Bool {
87
- #if canImport(AlarmKit)
91
+ #if canImport(AlarmKit)
88
92
  if #available(iOS 26.0, *) { return AlarmKitBackend.shared.isAuthorized }
89
- #endif
93
+ #endif
90
94
  return false
91
95
  }
92
-
96
+
93
97
  private func cancelNative(_ id: String) {
94
98
  AlarmRingPresenter.dismiss(id)
95
99
  NotificationBackend.shared.cancel(id)
96
- #if canImport(AlarmKit)
100
+ #if canImport(AlarmKit)
97
101
  if #available(iOS 26.0, *) { AlarmKitBackend.shared.cancel(id) }
98
- #endif
102
+ #endif
99
103
  }
100
-
104
+
101
105
  // MARK: - Permissions
102
-
106
+
103
107
  /// Same keys as Android's `permissionMap()`. The Android-only flags are always
104
108
  /// true here so shared UI code can treat them as granted.
105
109
  private func permissionMap(_ done: @escaping ([String: Any]) -> Void) {
106
110
  NotificationBackend.shared.status { notifications in
107
111
  var status = notifications
108
112
  var backend = "notifications"
109
- #if canImport(AlarmKit)
113
+ #if canImport(AlarmKit)
110
114
  if #available(iOS 26.0, *) {
111
115
  status = AlarmKitBackend.shared.status
112
116
  backend = "alarmkit"
113
117
  }
114
- #endif
118
+ #endif
115
119
  done([
116
120
  "status": status,
117
121
  "notifications": status == "granted" || notifications == "granted",
@@ -122,15 +126,15 @@ final class AlarmModule: RCTEventEmitter {
122
126
  ])
123
127
  }
124
128
  }
125
-
129
+
126
130
  private func notificationStatus() async -> String {
127
131
  await withCheckedContinuation { c in NotificationBackend.shared.status { c.resume(returning: $0) } }
128
132
  }
129
-
133
+
130
134
  private func requestNotifications() async {
131
135
  _ = await withCheckedContinuation { c in NotificationBackend.shared.request { c.resume(returning: $0) } }
132
136
  }
133
-
137
+
134
138
  /// iOS never re-prompts after a denial, so (like Android for its special
135
139
  /// permissions) send the user to the app's Settings page.
136
140
  @MainActor private func openSettings() {
@@ -138,16 +142,16 @@ final class AlarmModule: RCTEventEmitter {
138
142
  UIApplication.shared.open(url)
139
143
  }
140
144
  }
141
-
145
+
142
146
  @objc(checkPermission:reject:)
143
147
  func checkPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
144
148
  permissionMap { resolve($0) }
145
149
  }
146
-
150
+
147
151
  @objc(requestPermission:reject:)
148
152
  func requestPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
149
153
  Task {
150
- #if canImport(AlarmKit)
154
+ #if canImport(AlarmKit)
151
155
  if #available(iOS 26.0, *) {
152
156
  let kit = AlarmKitBackend.shared
153
157
  if kit.status == "notDetermined" { _ = await kit.requestAuthorization() }
@@ -157,7 +161,7 @@ final class AlarmModule: RCTEventEmitter {
157
161
  }
158
162
  await self.openSettings()
159
163
  }
160
- #endif
164
+ #endif
161
165
  // Notifications back the fallback path.
162
166
  let notifications = await self.notificationStatus()
163
167
  if notifications == "notDetermined" {
@@ -168,42 +172,42 @@ final class AlarmModule: RCTEventEmitter {
168
172
  self.permissionMap { resolve($0) }
169
173
  }
170
174
  }
171
-
175
+
172
176
  @objc(canScheduleExactAlarms:reject:)
173
177
  func canScheduleExactAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
174
178
  permissionMap { (map: [String: Any]) in
175
179
  resolve((map["canScheduleExact"] as? Bool) ?? false)
176
180
  }
177
181
  }
178
-
182
+
179
183
  /// Android OEM concept; iOS has no such screen.
180
184
  @objc(openAutoStartSettings:reject:)
181
185
  func openAutoStartSettings(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
182
186
  resolve(false)
183
187
  }
184
-
188
+
185
189
  // MARK: - Scheduling
186
-
190
+
187
191
  @objc(scheduleAlarm:resolve:reject:)
188
192
  func scheduleAlarm(_ config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
189
193
  let dict = config as? [String: Any] ?? [:]
190
194
  let id = (dict["alarmId"] as? String) ?? UUID().uuidString
191
195
  arm(AlarmData.from(config: dict, id: id), code: "E_SCHEDULE", reject: reject) { resolve(id) }
192
196
  }
193
-
197
+
194
198
  @objc(updateAlarm:config:resolve:reject:)
195
199
  func updateAlarm(_ alarmId: String, config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
196
200
  guard AlarmStore.get(alarmId) != nil else { return resolve(false) }
197
201
  let dict = config as? [String: Any] ?? [:]
198
202
  arm(AlarmData.from(config: dict, id: alarmId), code: "E_UPDATE", reject: reject) { resolve(true) }
199
203
  }
200
-
204
+
201
205
  /// Cancel → persist → schedule, the same sequence as Android.
202
206
  private func arm(_ alarm: AlarmData, code: String, reject: @escaping RCTPromiseRejectBlock, done: @escaping () -> Void) {
203
207
  cancelNative(alarm.id)
204
208
  AlarmStore.save(alarm)
205
-
206
- #if canImport(AlarmKit)
209
+
210
+ #if canImport(AlarmKit)
207
211
  if #available(iOS 26.0, *), useAlarmKit {
208
212
  Task {
209
213
  do {
@@ -215,8 +219,8 @@ final class AlarmModule: RCTEventEmitter {
215
219
  }
216
220
  return
217
221
  }
218
- #endif
219
-
222
+ #endif
223
+
220
224
  NotificationBackend.shared.status { status in
221
225
  guard status == "granted" else {
222
226
  // Mirrors Android's SecurityException when the exact-alarm permission is missing.
@@ -227,7 +231,7 @@ final class AlarmModule: RCTEventEmitter {
227
231
  }
228
232
  }
229
233
  }
230
-
234
+
231
235
  @objc(cancelAlarm:resolve:reject:)
232
236
  func cancelAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
233
237
  let existed = AlarmStore.get(alarmId) != nil
@@ -235,31 +239,31 @@ final class AlarmModule: RCTEventEmitter {
235
239
  AlarmStore.remove(alarmId)
236
240
  resolve(existed)
237
241
  }
238
-
242
+
239
243
  @objc(cancelAllAlarms:reject:)
240
244
  func cancelAllAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
241
245
  AlarmRingPresenter.dismiss()
242
246
  AlarmStore.all().forEach { cancelNative($0.id) }
243
247
  NotificationBackend.shared.cancelAll()
244
- #if canImport(AlarmKit)
248
+ #if canImport(AlarmKit)
245
249
  if #available(iOS 26.0, *) { AlarmKitBackend.shared.cancelAll() }
246
- #endif
250
+ #endif
247
251
  AlarmStore.clear()
248
252
  resolve(nil)
249
253
  }
250
-
254
+
251
255
  // MARK: - Control (while ringing)
252
-
256
+
253
257
  @objc(stopAlarm:resolve:reject:)
254
258
  func stopAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
255
259
  AlarmRingPresenter.dismiss(alarmId)
256
260
  NotificationBackend.shared.stop(alarmId)
257
- #if canImport(AlarmKit)
261
+ #if canImport(AlarmKit)
258
262
  if #available(iOS 26.0, *) { AlarmKitBackend.shared.stop(alarmId) }
259
- #endif
263
+ #endif
260
264
  resolve(nil)
261
265
  }
262
-
266
+
263
267
  @objc(snoozeAlarm:minutes:resolve:reject:)
264
268
  func snoozeAlarm(_ alarmId: String, minutes: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
265
269
  guard let alarm = AlarmStore.get(alarmId) else {
@@ -267,8 +271,8 @@ final class AlarmModule: RCTEventEmitter {
267
271
  }
268
272
  let mins = minutes > 0 ? Int(minutes) : alarm.snoozeMinutes
269
273
  AlarmRingPresenter.dismiss(alarmId)
270
-
271
- #if canImport(AlarmKit)
274
+
275
+ #if canImport(AlarmKit)
272
276
  if #available(iOS 26.0, *), useAlarmKit {
273
277
  Task {
274
278
  do {
@@ -280,36 +284,36 @@ final class AlarmModule: RCTEventEmitter {
280
284
  }
281
285
  return
282
286
  }
283
- #endif
284
-
287
+ #endif
288
+
285
289
  let wasRinging = NotificationBackend.shared.ringing?.id == alarmId
286
290
  NotificationBackend.shared.snooze(alarm, minutes: mins)
287
291
  if wasRinging { emit(Self.eventSnoozed, alarm) }
288
292
  resolve(nil)
289
293
  }
290
-
294
+
291
295
  /// The alarm currently ringing (if any), so JS can show the ring UI on launch.
292
296
  @objc(getRingingAlarm:reject:)
293
297
  func getRingingAlarm(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
294
298
  var ringing = NotificationBackend.shared.ringing
295
- #if canImport(AlarmKit)
299
+ #if canImport(AlarmKit)
296
300
  if #available(iOS 26.0, *), ringing == nil { ringing = AlarmKitBackend.shared.ringingAlarm() }
297
- #endif
301
+ #endif
298
302
  resolve(ringing?.toDictionary())
299
303
  }
300
-
304
+
301
305
  // MARK: - Queries
302
-
306
+
303
307
  @objc(getAlarm:resolve:reject:)
304
308
  func getAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
305
309
  resolve(AlarmStore.get(alarmId)?.toDictionary())
306
310
  }
307
-
311
+
308
312
  @objc(getAllAlarms:reject:)
309
313
  func getAllAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
310
314
  resolve(AlarmStore.all().sorted { $0.timestamp < $1.timestamp }.map { $0.toDictionary() })
311
315
  }
312
-
316
+
313
317
  @objc(isScheduled:resolve:reject:)
314
318
  func isScheduled(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
315
319
  let a = AlarmStore.get(alarmId)
@@ -8,12 +8,15 @@ import UIKit
8
8
  /// so this shows when the alarm fires while the app is in the foreground, or when the
9
9
  /// user opens the app by tapping the alarm notification.
10
10
  final class AlarmRingPresenter {
11
- static let componentName = "AlarmRing"
12
-
11
+ private static let defaultComponentName = "AlarmRing"
12
+
13
13
  private static weak var controller: UIViewController?
14
14
  private static var alarmId: String?
15
-
16
- static func present(_ alarm: AlarmData, bridge: RCTBridge?, attempt: Int = 0) {
15
+
16
+ static func present(_ alarm: AlarmData, bridge: RCTBridge?,
17
+ componentName: String = defaultComponentName,
18
+ componentProps: [String: Any] = [:],
19
+ attempt: Int = 0) {
17
20
  DispatchQueue.main.async {
18
21
  if controller != nil, alarmId == alarm.id {
19
22
  // Already showing (e.g. presented while backgrounded, now foregrounded):
@@ -23,7 +26,9 @@ final class AlarmRingPresenter {
23
26
  }
24
27
  // Cold start from a notification tap or the AlarmKit "Open" button: the root
25
28
  // surface or window may not be ready yet, so retry for up to ~15 s.
26
- guard let surface = makeSurface(bridge, props: alarm.toDictionary()),
29
+ guard let surface = makeSurface(bridge,
30
+ componentName: componentName,
31
+ props: alarm.toDictionary()),
27
32
  let top = topViewController() else {
28
33
  if attempt < 60 {
29
34
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
@@ -34,7 +39,7 @@ final class AlarmRingPresenter {
34
39
  }
35
40
  return
36
41
  }
37
-
42
+
38
43
  let vc = UIViewController()
39
44
  let root = RCTSurfaceHostingProxyRootView(surface: surface)
40
45
  root.backgroundColor = .systemBackground
@@ -42,7 +47,7 @@ final class AlarmRingPresenter {
42
47
  surface.start()
43
48
  vc.modalPresentationStyle = .fullScreen
44
49
  vc.isModalInPresentation = true
45
-
50
+
46
51
  let show = {
47
52
  top.present(vc, animated: true)
48
53
  controller = vc
@@ -56,7 +61,7 @@ final class AlarmRingPresenter {
56
61
  }
57
62
  }
58
63
  }
59
-
64
+
60
65
  /// The ring screen speaks the task via TTS, and iOS won't play it over the
61
66
  /// notification / AlarmKit sound, so stop that sound once the screen is up.
62
67
  /// Only while the app is frontmost: in the background the system alert is the
@@ -64,11 +69,11 @@ final class AlarmRingPresenter {
64
69
  private static func silenceIfActive(_ id: String) {
65
70
  guard UIApplication.shared.applicationState == .active else { return }
66
71
  NotificationBackend.shared.silence(id)
67
- #if canImport(AlarmKit)
72
+ #if canImport(AlarmKit)
68
73
  if #available(iOS 26.0, *) { AlarmKitBackend.shared.silence(id) }
69
- #endif
74
+ #endif
70
75
  }
71
-
76
+
72
77
  /// Called when ringing ends for any reason (stop, snooze, cancel).
73
78
  static func dismiss(_ id: String? = nil) {
74
79
  DispatchQueue.main.async {
@@ -78,18 +83,20 @@ final class AlarmRingPresenter {
78
83
  vc.dismiss(animated: true)
79
84
  }
80
85
  }
81
-
86
+
82
87
  private static let createSurface = NSSelectorFromString("createFabricSurfaceForModuleName:initialProperties:")
83
-
88
+
84
89
  /// `RCTSurfacePresenter` implements `createFabricSurfaceForModuleName:initialProperties:`
85
90
  /// without formally adopting `RCTSurfacePresenterStub`, and its header is ObjC++ (not
86
91
  /// importable from Swift), so call it by selector.
87
- private static func makeSurface(_ bridge: RCTBridge?, props: [String: Any]) -> RCTSurfaceProtocol? {
92
+ private static func makeSurface(_ bridge: RCTBridge?,
93
+ componentName: String,
94
+ props: [String: Any]) -> RCTSurfaceProtocol? {
88
95
  guard let presenter = surfacePresenter(bridge), presenter.responds(to: createSurface) else { return nil }
89
96
  return presenter.perform(createSurface, with: componentName, with: props)?
90
97
  .takeUnretainedValue() as? RCTSurfaceProtocol
91
98
  }
92
-
99
+
93
100
  /// Bridgeless mode skips `bridge.surfacePresenter` (and injection) for modules
94
101
  /// registered with `RCT_EXTERN_MODULE`, so fall back to the presenter of the app's
95
102
  /// root React surface, which `RCTFabricSurface` keeps in a weak `_surfacePresenter` ivar.
@@ -99,10 +106,10 @@ final class AlarmRingPresenter {
99
106
  let hosting = findHostingView(in: root),
100
107
  let surface = hosting.surface as AnyObject?,
101
108
  let ivar = class_getInstanceVariable(type(of: surface), "_surfacePresenter")
102
- else { return nil }
109
+ else { return nil }
103
110
  return object_getIvar(surface, ivar) as? NSObject
104
111
  }
105
-
112
+
106
113
  private static func findHostingView(in view: UIView) -> RCTSurfaceHostingView? {
107
114
  if let hosting = view as? RCTSurfaceHostingView { return hosting }
108
115
  for sub in view.subviews {
@@ -110,14 +117,14 @@ final class AlarmRingPresenter {
110
117
  }
111
118
  return nil
112
119
  }
113
-
120
+
114
121
  private static func keyWindow() -> UIWindow? {
115
122
  UIApplication.shared.connectedScenes
116
123
  .compactMap { $0 as? UIWindowScene }
117
124
  .flatMap { $0.windows }
118
125
  .first { $0.isKeyWindow }
119
126
  }
120
-
127
+
121
128
  private static func topViewController() -> UIViewController? {
122
129
  var top = keyWindow()?.rootViewController
123
130
  while let presented = top?.presentedViewController { top = presented }
@@ -4,45 +4,45 @@ import Foundation
4
4
  enum AlarmStore {
5
5
  private static let key = "RNAlarm.alarms"
6
6
  private static let lock = NSLock()
7
-
7
+
8
8
  static func all() -> [AlarmData] {
9
9
  lock.lock(); defer { lock.unlock() }
10
10
  return Array(load().values)
11
11
  }
12
-
12
+
13
13
  static func get(_ id: String) -> AlarmData? {
14
14
  lock.lock(); defer { lock.unlock() }
15
15
  return load()[id]
16
16
  }
17
-
17
+
18
18
  static func save(_ alarm: AlarmData) {
19
19
  lock.lock(); defer { lock.unlock() }
20
20
  var map = load()
21
21
  map[alarm.id] = alarm
22
22
  persist(map)
23
23
  }
24
-
24
+
25
25
  static func remove(_ id: String) {
26
26
  lock.lock(); defer { lock.unlock() }
27
27
  var map = load()
28
28
  map.removeValue(forKey: id)
29
29
  persist(map)
30
30
  }
31
-
31
+
32
32
  static func clear() {
33
33
  lock.lock(); defer { lock.unlock() }
34
34
  persist([:])
35
35
  }
36
-
36
+
37
37
  // MARK: - Private
38
-
38
+
39
39
  private static func load() -> [String: AlarmData] {
40
40
  guard let data = UserDefaults.standard.data(forKey: key),
41
41
  let map = try? JSONDecoder().decode([String: AlarmData].self, from: data)
42
- else { return [:] }
42
+ else { return [:] }
43
43
  return map
44
44
  }
45
-
45
+
46
46
  private static func persist(_ map: [String: AlarmData]) {
47
47
  if let data = try? JSONEncoder().encode(map) {
48
48
  UserDefaults.standard.set(data, forKey: key)