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.
- package/.bundle/config +2 -0
- package/AlarmService.d.ts +112 -0
- package/AlarmService.ts +196 -0
- package/README.md +385 -2
- package/android/app/build.gradle +119 -0
- package/android/app/debug.keystore +0 -0
- package/android/app/proguard-rules.pro +10 -0
- package/android/app/src/main/AndroidManifest.xml +81 -0
- package/android/app/src/main/java/com/reactnativealarm/MainActivity.kt +29 -0
- package/android/app/src/main/java/com/reactnativealarm/MainApplication.kt +29 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmData.kt +134 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmModule.kt +263 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmPackage.kt +24 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmReceiver.kt +43 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmRingActivity.kt +86 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmScheduler.kt +74 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmService.kt +285 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmStore.kt +13 -0
- package/android/app/src/main/java/com/reactnativealarm/alarm/BootReceiver.kt +29 -0
- package/android/app/src/main/res/drawable/rn_edit_text_material.xml +37 -0
- package/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png +0 -0
- package/android/app/src/main/res/values/strings.xml +3 -0
- package/android/app/src/main/res/values/styles.xml +9 -0
- package/android/build.gradle +21 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/android/gradle.properties +49 -0
- package/android/gradlew +248 -0
- package/android/gradlew.bat +98 -0
- package/android/settings.gradle +6 -0
- package/app.json +4 -0
- package/index.d.ts +20 -0
- package/index.js +12 -0
- package/index.ts +21 -0
- package/ios/.xcode.env +11 -0
- package/ios/Podfile +34 -0
- package/ios/Podfile.lock +2179 -0
- package/ios/ReactNativeAlarm/AppDelegate.swift +14 -0
- package/ios/ReactNativeAlarm/Images.xcassets/AppIcon.appiconset/Contents.json +53 -0
- package/ios/ReactNativeAlarm/Images.xcassets/Contents.json +6 -0
- package/ios/ReactNativeAlarm/Info.plist +79 -0
- package/ios/ReactNativeAlarm/LaunchScreen.storyboard +47 -0
- package/ios/ReactNativeAlarm/PrivacyInfo.xcprivacy +37 -0
- package/ios/ReactNativeAlarm/SceneDelegate.swift +56 -0
- package/ios/ReactNativeAlarm.xcodeproj/project.pbxproj +530 -0
- package/ios/ReactNativeAlarm.xcodeproj/xcshareddata/xcschemes/ReactNativeAlarm.xcscheme +88 -0
- package/ios/ReactNativeAlarm.xcworkspace/contents.xcworkspacedata +10 -0
- package/ios/alarm/AlarmData.swift +97 -0
- package/ios/alarm/AlarmKitBackend.swift +212 -0
- package/ios/alarm/AlarmModule.m +53 -0
- package/ios/alarm/AlarmModule.swift +284 -0
- package/ios/alarm/AlarmStore.swift +51 -0
- package/ios/alarm/NotificationBackend.swift +227 -0
- package/package.json +91 -3
- 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
|
+
}
|