react-native-alarms 0.0.6 → 0.0.8

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: [
@@ -77,41 +81,42 @@ final class AlarmModule: RCTEventEmitter {
77
81
  "timestamp": AlarmData.now(),
78
82
  "title": a.title,
79
83
  "body": a.body,
84
+ "image": a.image ?? "",
80
85
  "data": a.data,
81
86
  ])
82
87
  }
83
-
88
+
84
89
  // MARK: - Backend selection
85
-
90
+
86
91
  private var useAlarmKit: Bool {
87
- #if canImport(AlarmKit)
92
+ #if canImport(AlarmKit)
88
93
  if #available(iOS 26.0, *) { return AlarmKitBackend.shared.isAuthorized }
89
- #endif
94
+ #endif
90
95
  return false
91
96
  }
92
-
97
+
93
98
  private func cancelNative(_ id: String) {
94
99
  AlarmRingPresenter.dismiss(id)
95
100
  NotificationBackend.shared.cancel(id)
96
- #if canImport(AlarmKit)
101
+ #if canImport(AlarmKit)
97
102
  if #available(iOS 26.0, *) { AlarmKitBackend.shared.cancel(id) }
98
- #endif
103
+ #endif
99
104
  }
100
-
105
+
101
106
  // MARK: - Permissions
102
-
107
+
103
108
  /// Same keys as Android's `permissionMap()`. The Android-only flags are always
104
109
  /// true here so shared UI code can treat them as granted.
105
110
  private func permissionMap(_ done: @escaping ([String: Any]) -> Void) {
106
111
  NotificationBackend.shared.status { notifications in
107
112
  var status = notifications
108
113
  var backend = "notifications"
109
- #if canImport(AlarmKit)
114
+ #if canImport(AlarmKit)
110
115
  if #available(iOS 26.0, *) {
111
116
  status = AlarmKitBackend.shared.status
112
117
  backend = "alarmkit"
113
118
  }
114
- #endif
119
+ #endif
115
120
  done([
116
121
  "status": status,
117
122
  "notifications": status == "granted" || notifications == "granted",
@@ -122,15 +127,15 @@ final class AlarmModule: RCTEventEmitter {
122
127
  ])
123
128
  }
124
129
  }
125
-
130
+
126
131
  private func notificationStatus() async -> String {
127
132
  await withCheckedContinuation { c in NotificationBackend.shared.status { c.resume(returning: $0) } }
128
133
  }
129
-
134
+
130
135
  private func requestNotifications() async {
131
136
  _ = await withCheckedContinuation { c in NotificationBackend.shared.request { c.resume(returning: $0) } }
132
137
  }
133
-
138
+
134
139
  /// iOS never re-prompts after a denial, so (like Android for its special
135
140
  /// permissions) send the user to the app's Settings page.
136
141
  @MainActor private func openSettings() {
@@ -138,16 +143,16 @@ final class AlarmModule: RCTEventEmitter {
138
143
  UIApplication.shared.open(url)
139
144
  }
140
145
  }
141
-
146
+
142
147
  @objc(checkPermission:reject:)
143
148
  func checkPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
144
149
  permissionMap { resolve($0) }
145
150
  }
146
-
151
+
147
152
  @objc(requestPermission:reject:)
148
153
  func requestPermission(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
149
154
  Task {
150
- #if canImport(AlarmKit)
155
+ #if canImport(AlarmKit)
151
156
  if #available(iOS 26.0, *) {
152
157
  let kit = AlarmKitBackend.shared
153
158
  if kit.status == "notDetermined" { _ = await kit.requestAuthorization() }
@@ -157,7 +162,7 @@ final class AlarmModule: RCTEventEmitter {
157
162
  }
158
163
  await self.openSettings()
159
164
  }
160
- #endif
165
+ #endif
161
166
  // Notifications back the fallback path.
162
167
  let notifications = await self.notificationStatus()
163
168
  if notifications == "notDetermined" {
@@ -168,42 +173,42 @@ final class AlarmModule: RCTEventEmitter {
168
173
  self.permissionMap { resolve($0) }
169
174
  }
170
175
  }
171
-
176
+
172
177
  @objc(canScheduleExactAlarms:reject:)
173
178
  func canScheduleExactAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
174
179
  permissionMap { (map: [String: Any]) in
175
180
  resolve((map["canScheduleExact"] as? Bool) ?? false)
176
181
  }
177
182
  }
178
-
183
+
179
184
  /// Android OEM concept; iOS has no such screen.
180
185
  @objc(openAutoStartSettings:reject:)
181
186
  func openAutoStartSettings(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
182
187
  resolve(false)
183
188
  }
184
-
189
+
185
190
  // MARK: - Scheduling
186
-
191
+
187
192
  @objc(scheduleAlarm:resolve:reject:)
188
193
  func scheduleAlarm(_ config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
189
194
  let dict = config as? [String: Any] ?? [:]
190
195
  let id = (dict["alarmId"] as? String) ?? UUID().uuidString
191
196
  arm(AlarmData.from(config: dict, id: id), code: "E_SCHEDULE", reject: reject) { resolve(id) }
192
197
  }
193
-
198
+
194
199
  @objc(updateAlarm:config:resolve:reject:)
195
200
  func updateAlarm(_ alarmId: String, config: NSDictionary, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
196
201
  guard AlarmStore.get(alarmId) != nil else { return resolve(false) }
197
202
  let dict = config as? [String: Any] ?? [:]
198
203
  arm(AlarmData.from(config: dict, id: alarmId), code: "E_UPDATE", reject: reject) { resolve(true) }
199
204
  }
200
-
205
+
201
206
  /// Cancel → persist → schedule, the same sequence as Android.
202
207
  private func arm(_ alarm: AlarmData, code: String, reject: @escaping RCTPromiseRejectBlock, done: @escaping () -> Void) {
203
208
  cancelNative(alarm.id)
204
209
  AlarmStore.save(alarm)
205
-
206
- #if canImport(AlarmKit)
210
+
211
+ #if canImport(AlarmKit)
207
212
  if #available(iOS 26.0, *), useAlarmKit {
208
213
  Task {
209
214
  do {
@@ -215,8 +220,8 @@ final class AlarmModule: RCTEventEmitter {
215
220
  }
216
221
  return
217
222
  }
218
- #endif
219
-
223
+ #endif
224
+
220
225
  NotificationBackend.shared.status { status in
221
226
  guard status == "granted" else {
222
227
  // Mirrors Android's SecurityException when the exact-alarm permission is missing.
@@ -227,7 +232,7 @@ final class AlarmModule: RCTEventEmitter {
227
232
  }
228
233
  }
229
234
  }
230
-
235
+
231
236
  @objc(cancelAlarm:resolve:reject:)
232
237
  func cancelAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
233
238
  let existed = AlarmStore.get(alarmId) != nil
@@ -235,31 +240,31 @@ final class AlarmModule: RCTEventEmitter {
235
240
  AlarmStore.remove(alarmId)
236
241
  resolve(existed)
237
242
  }
238
-
243
+
239
244
  @objc(cancelAllAlarms:reject:)
240
245
  func cancelAllAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
241
246
  AlarmRingPresenter.dismiss()
242
247
  AlarmStore.all().forEach { cancelNative($0.id) }
243
248
  NotificationBackend.shared.cancelAll()
244
- #if canImport(AlarmKit)
249
+ #if canImport(AlarmKit)
245
250
  if #available(iOS 26.0, *) { AlarmKitBackend.shared.cancelAll() }
246
- #endif
251
+ #endif
247
252
  AlarmStore.clear()
248
253
  resolve(nil)
249
254
  }
250
-
255
+
251
256
  // MARK: - Control (while ringing)
252
-
257
+
253
258
  @objc(stopAlarm:resolve:reject:)
254
259
  func stopAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
255
260
  AlarmRingPresenter.dismiss(alarmId)
256
261
  NotificationBackend.shared.stop(alarmId)
257
- #if canImport(AlarmKit)
262
+ #if canImport(AlarmKit)
258
263
  if #available(iOS 26.0, *) { AlarmKitBackend.shared.stop(alarmId) }
259
- #endif
264
+ #endif
260
265
  resolve(nil)
261
266
  }
262
-
267
+
263
268
  @objc(snoozeAlarm:minutes:resolve:reject:)
264
269
  func snoozeAlarm(_ alarmId: String, minutes: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
265
270
  guard let alarm = AlarmStore.get(alarmId) else {
@@ -267,8 +272,8 @@ final class AlarmModule: RCTEventEmitter {
267
272
  }
268
273
  let mins = minutes > 0 ? Int(minutes) : alarm.snoozeMinutes
269
274
  AlarmRingPresenter.dismiss(alarmId)
270
-
271
- #if canImport(AlarmKit)
275
+
276
+ #if canImport(AlarmKit)
272
277
  if #available(iOS 26.0, *), useAlarmKit {
273
278
  Task {
274
279
  do {
@@ -280,36 +285,36 @@ final class AlarmModule: RCTEventEmitter {
280
285
  }
281
286
  return
282
287
  }
283
- #endif
284
-
288
+ #endif
289
+
285
290
  let wasRinging = NotificationBackend.shared.ringing?.id == alarmId
286
291
  NotificationBackend.shared.snooze(alarm, minutes: mins)
287
292
  if wasRinging { emit(Self.eventSnoozed, alarm) }
288
293
  resolve(nil)
289
294
  }
290
-
295
+
291
296
  /// The alarm currently ringing (if any), so JS can show the ring UI on launch.
292
297
  @objc(getRingingAlarm:reject:)
293
298
  func getRingingAlarm(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
294
299
  var ringing = NotificationBackend.shared.ringing
295
- #if canImport(AlarmKit)
300
+ #if canImport(AlarmKit)
296
301
  if #available(iOS 26.0, *), ringing == nil { ringing = AlarmKitBackend.shared.ringingAlarm() }
297
- #endif
302
+ #endif
298
303
  resolve(ringing?.toDictionary())
299
304
  }
300
-
305
+
301
306
  // MARK: - Queries
302
-
307
+
303
308
  @objc(getAlarm:resolve:reject:)
304
309
  func getAlarm(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
305
310
  resolve(AlarmStore.get(alarmId)?.toDictionary())
306
311
  }
307
-
312
+
308
313
  @objc(getAllAlarms:reject:)
309
314
  func getAllAlarms(_ resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
310
315
  resolve(AlarmStore.all().sorted { $0.timestamp < $1.timestamp }.map { $0.toDictionary() })
311
316
  }
312
-
317
+
313
318
  @objc(isScheduled:resolve:reject:)
314
319
  func isScheduled(_ alarmId: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
315
320
  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)