react-native-alarms 0.0.9 → 0.0.10

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 CHANGED
@@ -193,6 +193,20 @@ unsubscribeDismissed();
193
193
  unsubscribeSnoozed();
194
194
  ```
195
195
 
196
+ Alarm events are delivered to JavaScript only while the native module and JS
197
+ runtime are active; they are not a durable event queue. On app startup, query
198
+ `getRingingAlarm()` to recover an alarm that started while the app was closed:
199
+
200
+ ```typescript
201
+ const ringingAlarm = await AlarmService.getRingingAlarm();
202
+ if (ringingAlarm) {
203
+ // Restore any app-level state needed for the currently ringing alarm.
204
+ }
205
+ ```
206
+
207
+ Register event listeners as part of app initialization to receive subsequent
208
+ transitions. Native ring-screen presentation is independent of JS event delivery.
209
+
196
210
  ### Managing Alarms
197
211
 
198
212
  ```typescript
@@ -240,7 +254,7 @@ type AlarmConfig = {
240
254
  title: string; // Alarm title/label
241
255
  body?: string; // Alarm description
242
256
  image?: string; // file:// or https:// image URI for ring screen/notifications
243
- sound?: string; // 'default' or custom sound file name
257
+ sound?: string; // 'default' or a bundled sound filename, e.g. 'bell.wav'
244
258
  vibrate?: boolean; // Enable vibration (Android only)
245
259
  snoozeEnabled?: boolean; // Allow snoozing
246
260
  snoozeMinutes?: number; // Snooze duration in minutes
@@ -278,6 +292,28 @@ AlarmKit system alert itself does not display custom images, but the image URI i
278
292
  available to the React Native ring screen when it opens. Render it in that screen
279
293
  with React Native's `Image` component and `source={{ uri: image }}`.
280
294
 
295
+ #### Custom alarm sound
296
+
297
+ Set `sound` to the same bundled filename on both platforms. For example, with
298
+ `sound: 'bell.wav'`, include `bell.wav` in the iOS app target's Copy Bundle
299
+ Resources and add it to Android at
300
+ `android/app/src/main/res/raw/bell.wav` (create the `raw` directory if needed).
301
+ Android raw resource filenames must be lowercase and use only letters, numbers,
302
+ and underscores. iOS notification sounds must use a supported audio format and
303
+ meet Apple's notification sound duration and size limits.
304
+
305
+ ```ts
306
+ await AlarmService.scheduleAlarm({
307
+ timestamp: Date.now() + 60_000,
308
+ title: 'Wake up',
309
+ sound: 'bell.wav',
310
+ });
311
+ ```
312
+
313
+ The alarm uses the custom sound when the file is bundled for that platform.
314
+ Otherwise, including when `sound` is omitted or set to `'default'`, it uses the
315
+ platform's default alarm sound.
316
+
281
317
  > **How the native layer stores these:** `AlarmConfig.presentation` is resolved by `AlarmService` before being handed off to the native module. The resulting fields stored in the native `AlarmData` model are:
282
318
  >
283
319
  > | Field | Type | Default | Description |
@@ -138,9 +138,7 @@ class AlarmModule(private val ctx: ReactApplicationContext) : ReactContextBaseJa
138
138
  val id = if (config.hasKey("alarmId") && !config.isNull("alarmId"))
139
139
  config.getString("alarmId")!! else UUID.randomUUID().toString()
140
140
  val alarm = AlarmData.fromMap(config, id)
141
- AlarmScheduler.cancel(ctx, id)
142
- AlarmStore.save(ctx, alarm)
143
- AlarmScheduler.schedule(ctx, alarm)
141
+ scheduleReplacing(alarm)
144
142
  promise.resolve(id)
145
143
  } catch (e: Exception) {
146
144
  promise.reject("E_SCHEDULE", e.message, e)
@@ -152,15 +150,39 @@ class AlarmModule(private val ctx: ReactApplicationContext) : ReactContextBaseJa
152
150
  try {
153
151
  if (AlarmStore.get(ctx, alarmId) == null) return promise.resolve(false)
154
152
  val alarm = AlarmData.fromMap(config, alarmId)
155
- AlarmScheduler.cancel(ctx, alarmId)
156
- AlarmStore.save(ctx, alarm)
157
- AlarmScheduler.schedule(ctx, alarm)
153
+ scheduleReplacing(alarm)
158
154
  promise.resolve(true)
159
155
  } catch (e: Exception) {
160
156
  promise.reject("E_UPDATE", e.message, e)
161
157
  }
162
158
  }
163
159
 
160
+ private fun scheduleReplacing(alarm: AlarmData) {
161
+ val previous = AlarmStore.get(ctx, alarm.id)
162
+ AlarmScheduler.cancel(ctx, alarm.id)
163
+ try {
164
+ AlarmStore.save(ctx, alarm)
165
+ AlarmScheduler.schedule(ctx, alarm)
166
+ } catch (error: Exception) {
167
+ AlarmScheduler.cancel(ctx, alarm.id)
168
+ if (previous == null) {
169
+ AlarmStore.remove(ctx, alarm.id)
170
+ } else {
171
+ AlarmStore.save(ctx, previous)
172
+ try {
173
+ AlarmScheduler.schedule(ctx, previous)
174
+ } catch (restoreError: Exception) {
175
+ android.util.Log.e(
176
+ "AlarmModule",
177
+ "Could not restore alarm ${alarm.id} after scheduling failed",
178
+ restoreError
179
+ )
180
+ }
181
+ }
182
+ throw error
183
+ }
184
+ }
185
+
164
186
  @ReactMethod
165
187
  fun cancelAlarm(alarmId: String, promise: Promise) {
166
188
  val existed = AlarmStore.get(ctx, alarmId) != null
@@ -182,14 +204,14 @@ class AlarmModule(private val ctx: ReactApplicationContext) : ReactContextBaseJa
182
204
 
183
205
  @ReactMethod
184
206
  fun stopAlarm(alarmId: String, promise: Promise) {
185
- AlarmService.dismiss(ctx)
207
+ if (AlarmService.current?.id == alarmId) AlarmService.dismiss(ctx)
186
208
  promise.resolve(null)
187
209
  }
188
210
 
189
211
  @ReactMethod
190
212
  fun snoozeAlarm(alarmId: String, minutes: Double, promise: Promise) {
191
213
  val ringing = AlarmService.current
192
- if (ringing != null) {
214
+ if (ringing?.id == alarmId) {
193
215
  AlarmService.snooze(ctx, minutes.toInt())
194
216
  return promise.resolve(null)
195
217
  }
@@ -153,13 +153,55 @@ class AlarmService : Service() {
153
153
  // ---- Sound ----
154
154
 
155
155
  private fun startSound(alarm: AlarmData) {
156
- val uri = alarm.sound.takeIf { it.isNotBlank() && it != "default" }?.let { Uri.parse(it) }
157
- ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
156
+ val customUri = customSoundUri(alarm.sound)
157
+ if (customUri != null) {
158
+ try {
159
+ playSound(customUri)
160
+ return
161
+ } catch (e: Exception) {
162
+ Log.w(TAG, "Could not play custom alarm sound $customUri; using the default sound", e)
163
+ }
164
+ }
165
+
166
+ val defaultUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
158
167
  ?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
168
+ if (defaultUri == null) {
169
+ Log.e(TAG, "No default alarm or ringtone sound is available")
170
+ startVibration()
171
+ return
172
+ }
173
+ try {
174
+ playSound(defaultUri)
175
+ } catch (e: Exception) {
176
+ Log.e(TAG, "Could not play default alarm sound $defaultUri", e)
177
+ startVibration() // last resort: at least wake the user somehow
178
+ }
179
+ }
159
180
 
181
+ private fun customSoundUri(sound: String): Uri? {
182
+ if (sound.isBlank() || sound == "default") return null
183
+
184
+ val parsed = Uri.parse(sound)
185
+ if (parsed.scheme != null) return parsed
186
+
187
+ val filename = sound.substringAfterLast('/')
188
+ val resourceName = filename.substringBeforeLast('.', filename)
189
+ if (filename != sound || !resourceName.matches(Regex("[a-z][a-z0-9_]*"))) {
190
+ Log.w(TAG, "Invalid custom alarm sound name '$sound'; using the default sound")
191
+ return null
192
+ }
193
+
194
+ val resourceId = resources.getIdentifier(resourceName, "raw", packageName)
195
+ if (resourceId == 0) {
196
+ Log.w(TAG, "Custom alarm sound '$sound' was not found; using the default sound")
197
+ return null
198
+ }
199
+ return Uri.parse("android.resource://$packageName/$resourceId")
200
+ }
201
+
202
+ private fun playSound(uri: Uri) {
203
+ val mp = MediaPlayer()
160
204
  try {
161
- val mp = MediaPlayer()
162
- player = mp
163
205
  mp.apply {
164
206
  setAudioAttributes(
165
207
  AudioAttributes.Builder()
@@ -172,11 +214,10 @@ class AlarmService : Service() {
172
214
  prepare()
173
215
  start()
174
216
  }
175
- } catch (e: Exception) {
176
- Log.e(TAG, "Could not play $uri, vibrating only", e)
177
- runCatching { player?.release() }
178
- player = null
179
- startVibration() // last resort: at least wake the user somehow
217
+ player = mp
218
+ } catch (error: Exception) {
219
+ mp.release()
220
+ throw error
180
221
  }
181
222
  }
182
223
 
@@ -35,6 +35,23 @@ struct AlarmData: Codable {
35
35
 
36
36
  return props
37
37
  }
38
+
39
+ var bundledSoundName: String? {
40
+ guard !sound.isEmpty, sound != "default",
41
+ URL(fileURLWithPath: sound).lastPathComponent == sound else {
42
+ return nil
43
+ }
44
+
45
+ let filename = URL(fileURLWithPath: sound)
46
+ let name = filename.deletingPathExtension().lastPathComponent
47
+ let fileExtension = filename.pathExtension
48
+ guard !name.isEmpty, !fileExtension.isEmpty,
49
+ Bundle.main.url(forResource: name, withExtension: fileExtension) != nil else {
50
+ return nil
51
+ }
52
+
53
+ return sound
54
+ }
38
55
 
39
56
  /// Next time this alarm should ring, in ms since epoch, or 0 if never.
40
57
  func nextTrigger(from: Double = AlarmData.now()) -> Double {
@@ -138,8 +138,12 @@ final class AlarmKitBackend {
138
138
  metadata: RNAlarmMetadata(),
139
139
  tintColor: .blue
140
140
  )
141
- let sound: AlertConfiguration.AlertSound =
142
- (a.sound.isEmpty || a.sound == "default") ? .default : .named(a.sound)
141
+ let sound: AlertConfiguration.AlertSound
142
+ if let soundName = a.bundledSoundName {
143
+ sound = .named(soundName)
144
+ } else {
145
+ sound = .default
146
+ }
143
147
 
144
148
  return AlarmManager.AlarmConfiguration(
145
149
  schedule: schedule,
@@ -17,6 +17,11 @@ final class AlarmModule: RCTEventEmitter {
17
17
 
18
18
  private var hasListeners = false
19
19
  private var activeObserver: NSObjectProtocol?
20
+ private struct ScheduleTransaction {
21
+ let token: UUID
22
+ let original: AlarmData?
23
+ }
24
+ private var scheduleTransactions: [String: ScheduleTransaction] = [:]
20
25
 
21
26
  override init() {
22
27
  super.init()
@@ -95,7 +100,10 @@ final class AlarmModule: RCTEventEmitter {
95
100
  return false
96
101
  }
97
102
 
98
- private func cancelNative(_ id: String) {
103
+ private func cancelNative(_ id: String, preserveScheduleTransaction: Bool = false) {
104
+ if !preserveScheduleTransaction {
105
+ scheduleTransactions.removeValue(forKey: id)
106
+ }
99
107
  AlarmRingPresenter.dismiss(id)
100
108
  NotificationBackend.shared.cancel(id)
101
109
  #if canImport(AlarmKit)
@@ -203,19 +211,51 @@ final class AlarmModule: RCTEventEmitter {
203
211
  arm(AlarmData.from(config: dict, id: alarmId), code: "E_UPDATE", reject: reject) { resolve(true) }
204
212
  }
205
213
 
206
- /// Cancel → persist → schedule, the same sequence as Android.
214
+ /// Replaces the persisted alarm only after the platform scheduler accepts it.
207
215
  private func arm(_ alarm: AlarmData, code: String, reject: @escaping RCTPromiseRejectBlock, done: @escaping () -> Void) {
208
- cancelNative(alarm.id)
216
+ let original = scheduleTransactions[alarm.id].map { $0.original } ?? AlarmStore.get(alarm.id)
217
+ let token = UUID()
218
+ cancelNative(alarm.id, preserveScheduleTransaction: true)
219
+ scheduleTransactions[alarm.id] = ScheduleTransaction(token: token, original: original)
209
220
  AlarmStore.save(alarm)
210
-
221
+
222
+ scheduleNative(alarm) { error in
223
+ guard self.scheduleTransactions[alarm.id]?.token == token else {
224
+ reject("E_CANCELLED", "Alarm scheduling was superseded or cancelled", nil)
225
+ return
226
+ }
227
+ guard let error else {
228
+ self.scheduleTransactions.removeValue(forKey: alarm.id)
229
+ done()
230
+ return
231
+ }
232
+
233
+ self.cancelNative(alarm.id, preserveScheduleTransaction: true)
234
+ if let original {
235
+ AlarmStore.save(original)
236
+ self.scheduleNative(original) { restoreError in
237
+ if let restoreError {
238
+ NSLog("[react-native-alarms] Could not restore alarm %@ after scheduling failed: %@",
239
+ alarm.id, restoreError.localizedDescription)
240
+ }
241
+ }
242
+ } else {
243
+ AlarmStore.remove(alarm.id)
244
+ }
245
+ self.scheduleTransactions.removeValue(forKey: alarm.id)
246
+ reject(code, error.localizedDescription, error)
247
+ }
248
+ }
249
+
250
+ private func scheduleNative(_ alarm: AlarmData, completion: @escaping (Error?) -> Void) {
211
251
  #if canImport(AlarmKit)
212
252
  if #available(iOS 26.0, *), useAlarmKit {
213
253
  Task {
214
254
  do {
215
255
  try await AlarmKitBackend.shared.schedule(alarm)
216
- done()
256
+ completion(nil)
217
257
  } catch {
218
- reject(code, error.localizedDescription, error)
258
+ completion(error)
219
259
  }
220
260
  }
221
261
  return
@@ -224,11 +264,15 @@ final class AlarmModule: RCTEventEmitter {
224
264
 
225
265
  NotificationBackend.shared.status { status in
226
266
  guard status == "granted" else {
227
- // Mirrors Android's SecurityException when the exact-alarm permission is missing.
228
- return reject(code, "Alarm permission not granted; call requestPermission() first", nil)
267
+ let error = NSError(
268
+ domain: "ReactNativeAlarms",
269
+ code: 1,
270
+ userInfo: [NSLocalizedDescriptionKey: "Alarm permission not granted; call requestPermission() first"]
271
+ )
272
+ return completion(error)
229
273
  }
230
274
  NotificationBackend.shared.schedule(alarm) { error in
231
- if let error { reject(code, error.localizedDescription, error) } else { done() }
275
+ completion(error)
232
276
  }
233
277
  }
234
278
  }
@@ -23,6 +23,8 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
23
23
 
24
24
  /// The alarm whose notification is currently being shown (foreground) or was tapped.
25
25
  private(set) var ringing: AlarmData?
26
+ private var imageScheduleTokens: [String: UUID] = [:]
27
+ private let imageScheduleTokensLock = NSLock()
26
28
  /// Wired by `AlarmModule` so native events reach JS.
27
29
  var onEvent: ((String, AlarmData) -> Void)?
28
30
 
@@ -86,9 +88,9 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
86
88
  c.categoryIdentifier = Self.category
87
89
  c.userInfo = ["alarmId": a.id, "image": a.image ?? ""]
88
90
  if let attachment { c.attachments = [attachment] }
89
- c.sound = (a.sound.isEmpty || a.sound == "default")
90
- ? .default
91
- : UNNotificationSound(named: UNNotificationSoundName(rawValue: a.sound))
91
+ c.sound = a.bundledSoundName.map {
92
+ UNNotificationSound(named: UNNotificationSoundName(rawValue: $0))
93
+ } ?? .default
92
94
  // Breaks through Focus when the Time Sensitive Notifications capability is enabled;
93
95
  // silently downgraded to a normal notification otherwise.
94
96
  c.interruptionLevel = .timeSensitive
@@ -102,7 +104,12 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
102
104
 
103
105
  func schedule(_ a: AlarmData, completion: @escaping (Error?) -> Void) {
104
106
  cancel(a.id)
107
+ let token = UUID()
108
+ setImageScheduleToken(token, for: a.id)
105
109
  prepareAttachment(for: a.image) { attachment in
110
+ guard self.hasImageScheduleToken(token, for: a.id) else {
111
+ return completion(CancellationError())
112
+ }
106
113
  let cal = Calendar.current
107
114
  let base = Self.prefix + a.id
108
115
  let hm = cal.dateComponents([.hour, .minute], from: a.date)
@@ -141,7 +148,7 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
141
148
  requests.append(self.request(base, a, self.exactTrigger(ms: next), attachment))
142
149
  }
143
150
 
144
- self.add(requests, completion: completion)
151
+ self.add(requests, alarmId: a.id, token: token, completion: completion)
145
152
  }
146
153
  }
147
154
 
@@ -165,7 +172,9 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
165
172
  return completion(nil)
166
173
  }
167
174
 
168
- URLSession.shared.downloadTask(with: url) { temporaryURL, response, error in
175
+ var request = URLRequest(url: url)
176
+ request.timeoutInterval = 15
177
+ URLSession.shared.downloadTask(with: request) { temporaryURL, response, error in
169
178
  guard let temporaryURL, error == nil else {
170
179
  NSLog("[react-native-alarms] Could not download alarm image: %@", error?.localizedDescription ?? "empty response")
171
180
  return completion(nil)
@@ -195,26 +204,69 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
195
204
  return UNCalendarNotificationTrigger(dateMatching: dc, repeats: false)
196
205
  }
197
206
 
198
- private func add(_ requests: [UNNotificationRequest], completion: @escaping (Error?) -> Void) {
207
+ private func add(
208
+ _ requests: [UNNotificationRequest],
209
+ alarmId: String,
210
+ token: UUID,
211
+ completion: @escaping (Error?) -> Void
212
+ ) {
199
213
  let group = DispatchGroup()
214
+ let errorLock = NSLock()
200
215
  var firstError: Error?
201
216
  for r in requests {
202
217
  group.enter()
203
218
  center.add(r) { error in
219
+ errorLock.lock()
204
220
  if firstError == nil { firstError = error }
221
+ errorLock.unlock()
205
222
  group.leave()
206
223
  }
207
224
  }
208
- group.notify(queue: .main) { completion(firstError) }
225
+ group.notify(queue: .main) {
226
+ guard self.hasImageScheduleToken(token, for: alarmId) else {
227
+ self.center.removePendingNotificationRequests(withIdentifiers: requests.map(\.identifier))
228
+ return completion(CancellationError())
229
+ }
230
+ errorLock.lock()
231
+ let error = firstError
232
+ errorLock.unlock()
233
+ completion(error)
234
+ }
235
+ }
236
+
237
+ private func setImageScheduleToken(_ token: UUID, for alarmId: String) {
238
+ imageScheduleTokensLock.lock()
239
+ imageScheduleTokens[alarmId] = token
240
+ imageScheduleTokensLock.unlock()
241
+ }
242
+
243
+ private func hasImageScheduleToken(_ token: UUID, for alarmId: String) -> Bool {
244
+ imageScheduleTokensLock.lock()
245
+ defer { imageScheduleTokensLock.unlock() }
246
+ return imageScheduleTokens[alarmId] == token
209
247
  }
210
248
 
211
249
  /// One-off re-ring in `minutes`, equivalent to Android's `AlarmScheduler.scheduleSnooze`.
212
250
  func snooze(_ a: AlarmData, minutes: Int) {
213
251
  center.removeDeliveredNotifications(withIdentifiers: ids(for: a.id))
214
252
  let trigger = UNTimeIntervalNotificationTrigger(timeInterval: Double(max(1, minutes)) * 60, repeats: false)
253
+ let token = UUID()
254
+ setImageScheduleToken(token, for: a.id)
215
255
  prepareAttachment(for: a.image) { attachment in
216
- self.center.add(self.request(Self.prefix + a.id + ":snooze", a, trigger, attachment))
217
- if self.ringing?.id == a.id { self.ringing = nil }
256
+ guard self.hasImageScheduleToken(token, for: a.id) else { return }
257
+ let request = self.request(Self.prefix + a.id + ":snooze", a, trigger, attachment)
258
+ self.center.add(request) { error in
259
+ guard self.hasImageScheduleToken(token, for: a.id) else {
260
+ self.center.removePendingNotificationRequests(withIdentifiers: [request.identifier])
261
+ return
262
+ }
263
+ if let error {
264
+ NSLog("[react-native-alarms] Could not schedule snooze for %@: %@",
265
+ a.id, error.localizedDescription)
266
+ } else if self.ringing?.id == a.id {
267
+ self.ringing = nil
268
+ }
269
+ }
218
270
  }
219
271
  }
220
272
 
@@ -225,12 +277,16 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
225
277
  }
226
278
 
227
279
  func cancel(_ id: String) {
280
+ setImageScheduleToken(UUID(), for: id)
228
281
  center.removePendingNotificationRequests(withIdentifiers: ids(for: id))
229
282
  center.removeDeliveredNotifications(withIdentifiers: ids(for: id))
230
283
  if ringing?.id == id { ringing = nil }
231
284
  }
232
285
 
233
286
  func cancelAll() {
287
+ imageScheduleTokensLock.lock()
288
+ imageScheduleTokens.removeAll()
289
+ imageScheduleTokensLock.unlock()
234
290
  center.getPendingNotificationRequests { (requests: [UNNotificationRequest]) in
235
291
  let ours = requests.map { $0.identifier }.filter { $0.hasPrefix(Self.prefix) }
236
292
  self.center.removePendingNotificationRequests(withIdentifiers: ours)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-alarms",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "A powerful, cross-platform alarm management library for React Native",
5
5
  "private": false,
6
6
  "main": "index.ts",
@@ -98,9 +98,11 @@ export type AlarmConfig = {
98
98
  image?: string;
99
99
 
100
100
  /**
101
- * Alarm sound.
101
+ * Alarm sound file name, such as `"bell.wav"`.
102
102
  *
103
- * Use `"default"` or a bundled sound file name.
103
+ * Bundle the same filename with the iOS app and place it in Android's
104
+ * `android/app/src/main/res/raw` directory. Uses the system default if
105
+ * omitted, set to `"default"`, or the file is unavailable.
104
106
  */
105
107
  sound?: string;
106
108