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.
@@ -1,5 +1,6 @@
1
1
  import Foundation
2
2
  import UserNotifications
3
+ import UniformTypeIdentifiers
3
4
 
4
5
  /// Fallback backend for devices without AlarmKit (iOS < 26, or AlarmKit denied):
5
6
  /// local notifications with Stop / Snooze actions.
@@ -10,25 +11,25 @@ import UserNotifications
10
11
  @objc(RNAlarmNotificationBackend)
11
12
  final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
12
13
  static let shared = NotificationBackend()
13
-
14
+
14
15
  static let category = "RN_ALARM"
15
16
  static let actionSnooze = "RN_ALARM_SNOOZE"
16
17
  static let actionStop = "RN_ALARM_STOP"
17
18
  private static let prefix = "rnalarm:"
18
19
  /// How many `interval` occurrences to pre-schedule (iOS caps pending requests at 64).
19
20
  private static let intervalOccurrences = 12
20
-
21
+
21
22
  private let center = UNUserNotificationCenter.current()
22
-
23
+
23
24
  /// The alarm whose notification is currently being shown (foreground) or was tapped.
24
25
  private(set) var ringing: AlarmData?
25
26
  /// Wired by `AlarmModule` so native events reach JS.
26
27
  var onEvent: ((String, AlarmData) -> Void)?
27
-
28
+
28
29
  /// Delegate that was installed before us (e.g. Notifee); non-alarm notifications go to it.
29
30
  private weak var previousDelegate: UNUserNotificationCenterDelegate?
30
31
  private static var installed = false
31
-
32
+
32
33
  /// Installs the delegate and actions. Call once at launch so a tap on a
33
34
  /// notification that starts the app is delivered to the module.
34
35
  @objc static func install() {
@@ -51,9 +52,9 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
51
52
  ),
52
53
  ])
53
54
  }
54
-
55
+
55
56
  // MARK: - Permission
56
-
57
+
57
58
  /// 'granted' | 'denied' | 'notDetermined', same vocabulary as Android.
58
59
  func status(_ completion: @escaping (String) -> Void) {
59
60
  center.getNotificationSettings { settings in
@@ -64,82 +65,128 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
64
65
  }
65
66
  }
66
67
  }
67
-
68
+
68
69
  func request(_ completion: @escaping (Bool) -> Void) {
69
70
  center.requestAuthorization(options: [.alert, .sound, .badge]) { ok, _ in completion(ok) }
70
71
  }
71
-
72
+
72
73
  // MARK: - Scheduling
73
-
74
+
74
75
  private func ids(for alarmId: String) -> [String] {
75
76
  let base = Self.prefix + alarmId
76
77
  return [base, base + ":snooze"]
77
- + (1...7).map { base + ":wd\($0)" }
78
- + (0..<Self.intervalOccurrences).map { base + ":i\($0)" }
78
+ + (1...7).map { base + ":wd\($0)" }
79
+ + (0..<Self.intervalOccurrences).map { base + ":i\($0)" }
79
80
  }
80
-
81
- private func content(for a: AlarmData) -> UNMutableNotificationContent {
81
+
82
+ private func content(for a: AlarmData, attachment: UNNotificationAttachment? = nil) -> UNMutableNotificationContent {
82
83
  let c = UNMutableNotificationContent()
83
84
  c.title = a.title.isEmpty ? "Alarm" : a.title
84
85
  c.body = a.body.isEmpty ? "Alarm is ringing" : a.body
85
86
  c.categoryIdentifier = Self.category
86
- c.userInfo = ["alarmId": a.id]
87
+ c.userInfo = ["alarmId": a.id, "image": a.image ?? ""]
88
+ if let attachment { c.attachments = [attachment] }
87
89
  c.sound = (a.sound.isEmpty || a.sound == "default")
88
- ? .default
89
- : UNNotificationSound(named: UNNotificationSoundName(rawValue: a.sound))
90
+ ? .default
91
+ : UNNotificationSound(named: UNNotificationSoundName(rawValue: a.sound))
90
92
  // Breaks through Focus when the Time Sensitive Notifications capability is enabled;
91
93
  // silently downgraded to a normal notification otherwise.
92
94
  c.interruptionLevel = .timeSensitive
93
95
  return c
94
96
  }
95
-
96
- private func request(_ id: String, _ a: AlarmData, _ trigger: UNNotificationTrigger) -> UNNotificationRequest {
97
- UNNotificationRequest(identifier: id, content: content(for: a), trigger: trigger)
97
+
98
+ private func request(_ id: String, _ a: AlarmData, _ trigger: UNNotificationTrigger,
99
+ _ attachment: UNNotificationAttachment? = nil) -> UNNotificationRequest {
100
+ UNNotificationRequest(identifier: id, content: content(for: a, attachment: attachment), trigger: trigger)
98
101
  }
99
-
102
+
100
103
  func schedule(_ a: AlarmData, completion: @escaping (Error?) -> Void) {
101
104
  cancel(a.id)
102
- let cal = Calendar.current
103
- let base = Self.prefix + a.id
104
- let hm = cal.dateComponents([.hour, .minute], from: a.date)
105
- var requests: [UNNotificationRequest] = []
105
+ prepareAttachment(for: a.image) { attachment in
106
+ let cal = Calendar.current
107
+ let base = Self.prefix + a.id
108
+ let hm = cal.dateComponents([.hour, .minute], from: a.date)
109
+ var requests: [UNNotificationRequest] = []
106
110
 
107
- switch a.repeatType {
108
- case "daily":
109
- var dc = DateComponents()
110
- dc.hour = hm.hour
111
- dc.minute = hm.minute
112
- requests.append(request(base, a, UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)))
113
-
114
- case "weekly":
115
- let days = a.weekdays.isEmpty ? Array(1...7) : a.weekdays.filter { (1...7).contains($0) }
116
- for d in days {
111
+ switch a.repeatType {
112
+ case "daily":
117
113
  var dc = DateComponents()
118
- dc.weekday = d
119
114
  dc.hour = hm.hour
120
115
  dc.minute = hm.minute
121
- requests.append(request(base + ":wd\(d)", a, UNCalendarNotificationTrigger(dateMatching: dc, repeats: true)))
122
- }
116
+ requests.append(self.request(base, a, UNCalendarNotificationTrigger(dateMatching: dc, repeats: true), attachment))
123
117
 
124
- case "interval":
125
- // iOS repeating interval triggers count from "now", not from `timestamp`, so
126
- // pre-schedule the next N exact occurrences instead.
127
- var t = a.nextTrigger()
128
- guard t > 0 else { return completion(nil) }
129
- for k in 0..<Self.intervalOccurrences {
130
- requests.append(request(base + ":i\(k)", a, exactTrigger(ms: t)))
131
- t += a.intervalMs
118
+ case "weekly":
119
+ let days = a.weekdays.isEmpty ? Array(1...7) : a.weekdays.filter { (1...7).contains($0) }
120
+ for d in days {
121
+ var dc = DateComponents()
122
+ dc.weekday = d
123
+ dc.hour = hm.hour
124
+ dc.minute = hm.minute
125
+ requests.append(self.request(base + ":wd\(d)", a, UNCalendarNotificationTrigger(dateMatching: dc, repeats: true), attachment))
126
+ }
127
+
128
+ case "interval":
129
+ // iOS repeating interval triggers count from "now", not from `timestamp`, so
130
+ // pre-schedule the next N exact occurrences instead.
131
+ var t = a.nextTrigger()
132
+ guard t > 0 else { return completion(nil) }
133
+ for k in 0..<Self.intervalOccurrences {
134
+ requests.append(self.request(base + ":i\(k)", a, self.exactTrigger(ms: t), attachment))
135
+ t += a.intervalMs
136
+ }
137
+
138
+ default:
139
+ let next = a.nextTrigger()
140
+ guard next > 0 else { return completion(nil) } // nothing in the future, same as Android
141
+ requests.append(self.request(base, a, self.exactTrigger(ms: next), attachment))
132
142
  }
133
143
 
134
- default:
135
- let next = a.nextTrigger()
136
- guard next > 0 else { return completion(nil) } // nothing in the future, same as Android
137
- requests.append(request(base, a, exactTrigger(ms: next)))
144
+ self.add(requests, completion: completion)
138
145
  }
139
-
140
- add(requests, completion: completion)
141
146
  }
142
147
 
148
+ private func prepareAttachment(for image: String?, completion: @escaping (UNNotificationAttachment?) -> Void) {
149
+ guard let image, !image.isEmpty else { return completion(nil) }
150
+ guard let url = URL(string: image) else {
151
+ NSLog("[react-native-alarms] Invalid image URI: %@", image)
152
+ return completion(nil)
153
+ }
154
+ if url.isFileURL {
155
+ do {
156
+ completion(try UNNotificationAttachment(identifier: "alarm-image", url: url, options: nil))
157
+ } catch {
158
+ NSLog("[react-native-alarms] Could not attach alarm image: %@", error.localizedDescription)
159
+ completion(nil)
160
+ }
161
+ return
162
+ }
163
+ guard url.scheme?.lowercased() == "https" else {
164
+ NSLog("[react-native-alarms] Alarm image URI must use file:// or https://: %@", image)
165
+ return completion(nil)
166
+ }
167
+
168
+ URLSession.shared.downloadTask(with: url) { temporaryURL, response, error in
169
+ guard let temporaryURL, error == nil else {
170
+ NSLog("[react-native-alarms] Could not download alarm image: %@", error?.localizedDescription ?? "empty response")
171
+ return completion(nil)
172
+ }
173
+ let mimeType = response?.mimeType
174
+ let ext = url.pathExtension.isEmpty
175
+ ? (mimeType.flatMap { UTType(mimeType: $0)?.preferredFilenameExtension } ?? "jpg")
176
+ : url.pathExtension
177
+ let destination = FileManager.default.temporaryDirectory
178
+ .appendingPathComponent(UUID().uuidString)
179
+ .appendingPathExtension(ext)
180
+ do {
181
+ try FileManager.default.moveItem(at: temporaryURL, to: destination)
182
+ completion(try UNNotificationAttachment(identifier: "alarm-image", url: destination, options: nil))
183
+ } catch {
184
+ NSLog("[react-native-alarms] Could not attach downloaded alarm image: %@", error.localizedDescription)
185
+ completion(nil)
186
+ }
187
+ }.resume()
188
+ }
189
+
143
190
  private func exactTrigger(ms: Double) -> UNCalendarNotificationTrigger {
144
191
  let dc = Calendar.current.dateComponents(
145
192
  [.year, .month, .day, .hour, .minute, .second],
@@ -147,7 +194,7 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
147
194
  )
148
195
  return UNCalendarNotificationTrigger(dateMatching: dc, repeats: false)
149
196
  }
150
-
197
+
151
198
  private func add(_ requests: [UNNotificationRequest], completion: @escaping (Error?) -> Void) {
152
199
  let group = DispatchGroup()
153
200
  var firstError: Error?
@@ -160,27 +207,29 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
160
207
  }
161
208
  group.notify(queue: .main) { completion(firstError) }
162
209
  }
163
-
210
+
164
211
  /// One-off re-ring in `minutes`, equivalent to Android's `AlarmScheduler.scheduleSnooze`.
165
212
  func snooze(_ a: AlarmData, minutes: Int) {
166
213
  center.removeDeliveredNotifications(withIdentifiers: ids(for: a.id))
167
214
  let trigger = UNTimeIntervalNotificationTrigger(timeInterval: Double(max(1, minutes)) * 60, repeats: false)
168
- center.add(request(Self.prefix + a.id + ":snooze", a, trigger))
169
- if ringing?.id == a.id { ringing = nil }
215
+ 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 }
218
+ }
170
219
  }
171
-
220
+
172
221
  /// Stops the notification's sound (by clearing the delivered notification) while the
173
222
  /// in-app ring screen takes over; `ringing` stays set so the session is still live.
174
223
  func silence(_ id: String) {
175
224
  center.removeDeliveredNotifications(withIdentifiers: ids(for: id))
176
225
  }
177
-
226
+
178
227
  func cancel(_ id: String) {
179
228
  center.removePendingNotificationRequests(withIdentifiers: ids(for: id))
180
229
  center.removeDeliveredNotifications(withIdentifiers: ids(for: id))
181
230
  if ringing?.id == id { ringing = nil }
182
231
  }
183
-
232
+
184
233
  func cancelAll() {
185
234
  center.getPendingNotificationRequests { (requests: [UNNotificationRequest]) in
186
235
  let ours = requests.map { $0.identifier }.filter { $0.hasPrefix(Self.prefix) }
@@ -192,7 +241,7 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
192
241
  }
193
242
  ringing = nil
194
243
  }
195
-
244
+
196
245
  /// Dismiss: clear the shown notification and, like Android, drop a pending snooze.
197
246
  func stop(_ id: String) {
198
247
  center.removeDeliveredNotifications(withIdentifiers: ids(for: id))
@@ -202,14 +251,14 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
202
251
  onEvent?(AlarmModule.eventDismissed, a)
203
252
  }
204
253
  }
205
-
254
+
206
255
  // MARK: - UNUserNotificationCenterDelegate
207
-
256
+
208
257
  private func alarm(from notification: UNNotification) -> AlarmData? {
209
258
  guard let id = notification.request.content.userInfo["alarmId"] as? String else { return nil }
210
259
  return AlarmStore.get(id)
211
260
  }
212
-
261
+
213
262
  func userNotificationCenter(
214
263
  _ center: UNUserNotificationCenter,
215
264
  willPresent notification: UNNotification,
@@ -229,7 +278,7 @@ final class NotificationBackend: NSObject, UNUserNotificationCenterDelegate {
229
278
  // The ring screen takes over; keep the sound but skip the banner when it can show.
230
279
  completionHandler(onEvent == nil ? [.banner, .list, .sound] : [.list, .sound])
231
280
  }
232
-
281
+
233
282
  func userNotificationCenter(
234
283
  _ center: UNUserNotificationCenter,
235
284
  didReceive response: UNNotificationResponse,
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "react-native-alarms",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "A powerful, cross-platform alarm management library for React Native",
5
5
  "private": false,
6
6
  "main": "index.ts",
7
- "types": "index.d.ts",
7
+ "types": "index.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./index.d.ts",
10
+ "types": "./index.ts",
11
11
  "default": "./index.ts"
12
12
  },
13
13
  "./service": {
14
- "types": "./AlarmService.d.ts",
14
+ "types": "./AlarmService.ts",
15
15
  "default": "./AlarmService.ts"
16
16
  },
17
17
  "./package.json": "./package.json"
@@ -1,109 +1,162 @@
1
1
  import type { TurboModule } from 'react-native';
2
2
  import { NativeModules, Platform, TurboModuleRegistry } from 'react-native';
3
+ import type { AlarmPermission, AlarmConfig, AlarmProp } from '../src/types';
3
4
 
4
5
  /**
5
- * Shared contract of the native `Alarm` module.
6
- *
7
- * Both platforms register a legacy-style module named `Alarm` with these exact
8
- * method names (Android: AlarmModule.kt, iOS: ios/alarm/AlarmModule.swift) and
9
- * emit the same events through `NativeEventEmitter`:
10
- * onAlarmFired | onAlarmDismissed | onAlarmSnoozed -> AlarmEvent
6
+ * Native Alarm module interface.
11
7
  */
12
-
13
- /** type: 'none' | 'daily' | 'weekly' | 'interval' */
14
- export type AlarmRepeat = {
15
- type: string;
16
- weekdays?: number[]; // 1 = Sunday ... 7 = Saturday (weekly)
17
- intervalMs?: number; // interval
18
- };
19
-
20
- export type AlarmConfig = {
21
- alarmId?: string;
22
- timestamp: number; // ms since epoch
23
- title: string;
24
- body?: string;
25
- sound?: string; // 'default' or a bundled sound file name
26
- vibrate?: boolean; // Android only (iOS alarms always vibrate per system settings)
27
- snoozeEnabled?: boolean;
28
- snoozeMinutes?: number;
29
- repeat?: AlarmRepeat;
30
- data?: string; // JSON string payload
31
- };
32
-
33
- export type Alarm = {
34
- alarmId: string;
35
- timestamp: number;
36
- title: string;
37
- body: string;
38
- sound: string;
39
- vibrate: boolean;
40
- snoozeEnabled: boolean;
41
- snoozeMinutes: number;
42
- repeat: AlarmRepeat;
43
- data: string;
44
- enabled: boolean;
45
- nextTriggerAt: number; // 0 if none
46
- };
47
-
48
- /** status: 'granted' | 'denied' | 'notDetermined' */
49
- export type AlarmPermission = {
50
- status: string;
51
- notifications: boolean;
52
- canScheduleExact: boolean;
53
- canDrawOverlays: boolean; // Android only; always true on iOS
54
- canUseFullScreenIntent: boolean; // Android only; always true on iOS
55
- backend?: string; // iOS only: 'alarmkit' (iOS 26+) | 'notifications'
56
- };
57
-
58
- export type AlarmEvent = {
59
- alarmId: string;
60
- timestamp: number;
61
- title: string;
62
- body: string;
63
- data: string;
64
- };
65
-
66
8
  export interface Spec extends TurboModule {
67
- // ---------- Permissions ----------
9
+ // ---------------------------------------------------------------------------
10
+ // Permissions
11
+ // ---------------------------------------------------------------------------
12
+
13
+ /**
14
+ * Returns the current permission status.
15
+ */
68
16
  checkPermission(): Promise<AlarmPermission>;
17
+
18
+ /**
19
+ * Requests any required permissions.
20
+ */
69
21
  requestPermission(): Promise<AlarmPermission>;
22
+
23
+ /**
24
+ * Checks whether exact alarms can be scheduled.
25
+ *
26
+ * Android only.
27
+ */
70
28
  canScheduleExactAlarms(): Promise<boolean>;
71
- /** Opens the vendor Autostart screen on Android; resolves false on iOS. */
29
+
30
+ /**
31
+ * Opens the device's Autostart settings screen.
32
+ *
33
+ * Android:
34
+ * Opens the vendor-specific Autostart page when available.
35
+ *
36
+ * iOS:
37
+ * Resolves `false`.
38
+ */
72
39
  openAutoStartSettings(): Promise<boolean>;
73
40
 
74
- // ---------- Scheduling ----------
41
+ // ---------------------------------------------------------------------------
42
+ // Scheduling
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * Schedules a new alarm.
47
+ *
48
+ * Returns the generated or provided alarm ID.
49
+ */
75
50
  scheduleAlarm(config: AlarmConfig): Promise<string>;
51
+
52
+ /**
53
+ * Updates an existing alarm.
54
+ *
55
+ * Returns `true` if the update succeeds.
56
+ */
76
57
  updateAlarm(alarmId: string, config: AlarmConfig): Promise<boolean>;
58
+
59
+ /**
60
+ * Cancels a specific alarm.
61
+ */
77
62
  cancelAlarm(alarmId: string): Promise<boolean>;
63
+
64
+ /**
65
+ * Cancels every scheduled alarm.
66
+ */
78
67
  cancelAllAlarms(): Promise<void>;
79
68
 
80
- // ---------- Control (while ringing) ----------
69
+ // ---------------------------------------------------------------------------
70
+ // Ringing Controls
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Stops the currently ringing alarm.
75
+ */
81
76
  stopAlarm(alarmId: string): Promise<void>;
77
+
78
+ /**
79
+ * Snoozes the currently ringing alarm.
80
+ *
81
+ * @param alarmId Alarm identifier.
82
+ * @param minutes Snooze duration in minutes.
83
+ */
82
84
  snoozeAlarm(alarmId: string, minutes: number): Promise<void>;
83
- getRingingAlarm(): Promise<Alarm | null>;
84
85
 
85
- // ---------- Queries ----------
86
- getAlarm(alarmId: string): Promise<Alarm | null>;
87
- getAllAlarms(): Promise<Alarm[]>;
86
+ /**
87
+ * Returns the currently ringing alarm, if one exists.
88
+ */
89
+ getRingingAlarm(): Promise<AlarmProp | null>;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Queries
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Returns a scheduled alarm by ID.
97
+ */
98
+ getAlarm(alarmId: string): Promise<AlarmProp | null>;
99
+
100
+ /**
101
+ * Returns every scheduled alarm.
102
+ */
103
+ getAllAlarms(): Promise<AlarmProp[]>;
104
+
105
+ /**
106
+ * Checks whether an alarm is scheduled.
107
+ */
88
108
  isScheduled(alarmId: string): Promise<boolean>;
89
109
 
90
- // ---------- Events (used by NativeEventEmitter) ----------
110
+ // ---------------------------------------------------------------------------
111
+ // NativeEventEmitter Support
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Internal listener registration.
116
+ */
91
117
  addListener(eventName: string): void;
118
+
119
+ /**
120
+ * Internal listener cleanup.
121
+ */
92
122
  removeListeners(count: number): void;
93
123
  }
94
124
 
125
+ /**
126
+ * Error shown when the native module is unavailable.
127
+ */
95
128
  const LINKING_ERROR =
96
129
  "react-native-alarms: the native 'Alarm' module is not linked. " +
97
130
  Platform.select({
98
131
  ios: "Run 'pod install' in the ios folder, then rebuild the app.",
99
- default: 'Rebuild the app after installing the package.',
132
+ default:
133
+ 'Rebuild the app after installing the package. Expo Go is unsupported; use a development build.',
100
134
  });
101
135
 
102
- // Both platforms register a legacy module, reachable through either registry
103
- // depending on the architecture. Throw on first use, not on import.
136
+ export function getNativeAlarmModule(): Spec | null {
137
+ return TurboModuleRegistry.get<Spec>('Alarm') ?? NativeModules.Alarm ?? null;
138
+ }
139
+
140
+ /**
141
+ * Cross-platform native Alarm module.
142
+ *
143
+ * Supports both the legacy Native Module system and the New Architecture
144
+ * (TurboModules). The module is resolved lazily so importing it does not
145
+ * immediately throw if native linking is missing.
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * import Alarm from 'react-native-alarms';
150
+ *
151
+ * const id = await Alarm.scheduleAlarm({
152
+ * timestamp: Date.now() + 60000,
153
+ * title: 'Meeting',
154
+ * body: 'Standup starts now',
155
+ * });
156
+ * ```
157
+ */
104
158
  const Alarm: Spec =
105
- TurboModuleRegistry.get<Spec>('Alarm') ??
106
- NativeModules.Alarm ??
159
+ getNativeAlarmModule() ??
107
160
  new Proxy({} as Spec, {
108
161
  get() {
109
162
  throw new Error(LINKING_ERROR);