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.
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # React Native Alarms 🔔
2
2
 
3
- A powerful, cross-platform alarm management library for React Native. Schedule, manage, and control alarms on both Android and iOS with a unified API.
3
+ A cross-platform native scheduling, reminder, and wake-up system for React Native. Schedule time-sensitive events on Android and iOS, deliver reminders, and present a registered custom React Native screen when a scheduled event fires—using one unified API.
4
+
5
+ Although it can power a traditional alarm clock, react-native-alarms is also useful for exact-time reminders, scheduled check-ins, custom lock-screen experiences, and time-based app workflows. Build the experience around your own UI and event data instead of being limited to a standard alarm screen.
6
+
7
+ > **Platform note:** The operating system controls background delivery and screen presentation. Exact timing, full-screen presentation, and behavior when an app is terminated depend on platform version, permissions, device settings, and the native scheduling mechanism. This library does not provide unrestricted background JavaScript execution.
8
+
4
9
 
5
10
  [![npm version](https://badge.fury.io/js/react-native-alarms.svg)](https://badge.fury.io/js/react-native-alarms)
6
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
@@ -39,7 +44,7 @@ yarn add react-native-alarms
39
44
 
40
45
  ### Native Module Linking
41
46
 
42
- Since this is a native module, you'll need to link it:
47
+ For a bare React Native app, install the iOS pods and rebuild:
43
48
 
44
49
  ```bash
45
50
  cd ios && pod install && cd ..
@@ -49,11 +54,32 @@ For Android, the native code auto-links, including the permissions, ring activit
49
54
  service and receivers in its manifest. No `MainApplication` or `AndroidManifest.xml`
50
55
  changes are needed. Rebuild the app after installing.
51
56
 
57
+ ### Expo
58
+
59
+ The library works with Expo projects that generate and build native projects
60
+ (prebuild, EAS Build, or a development build). It uses React Native autolinking and
61
+ does not require an Expo config plugin:
62
+
63
+ ```bash
64
+ npm install react-native-alarms
65
+ npx expo prebuild
66
+ npx expo run:android
67
+ # or
68
+ npx expo run:ios
69
+ ```
70
+
71
+ Rebuild the native app after installing or upgrading the library. **Expo Go is not
72
+ supported** because it cannot load this library's native alarm module. Existing bare
73
+ React Native installation and setup remain unchanged.
74
+ Register the `AlarmRing` component in your app's JavaScript entry point as described
75
+ below; Expo Router apps should do this from a custom entry point before loading
76
+ `expo-router/entry`.
77
+
52
78
  ### Register the ring screen
53
79
 
54
80
  When an alarm rings, both platforms show a full-screen React component registered as
55
81
  `AlarmRing`, with the alarm (`alarmId`, `title`, `body`, `snoozeEnabled`,
56
- `snoozeMinutes`, `data`) as props. Register your own component in `index.js`:
82
+ `snoozeMinutes`, `data`, and optional `image`) as props. Register your own component in `index.js`:
57
83
 
58
84
  ```js
59
85
  import { AppRegistry } from 'react-native';
@@ -65,6 +91,44 @@ AppRegistry.registerComponent('AlarmRing', () => AlarmRingScreen);
65
91
  Call `Alarm.stopAlarm(alarmId)` or `Alarm.snoozeAlarm(alarmId, minutes)` from its
66
92
  buttons; the screen closes itself.
67
93
 
94
+ #### Custom presentation component
95
+
96
+ You can register **multiple** ring screens and choose which one fires per alarm using the `presentation` field in `AlarmConfig`:
97
+
98
+ ```js
99
+ // index.js
100
+ import { AppRegistry } from 'react-native';
101
+ import { AlarmRingScreen } from './src/AlarmRingScreen';
102
+ import { ReminderScreen } from './src/ReminderScreen';
103
+
104
+ AppRegistry.registerComponent('AlarmRing', () => AlarmRingScreen); // default
105
+ AppRegistry.registerComponent('Reminder', () => ReminderScreen); // custom
106
+ ```
107
+
108
+ ```ts
109
+ import { AlarmRegistry, AlarmService } from 'react-native-alarms/service';
110
+
111
+ // 1. Map your key to the registered component name
112
+ AlarmRegistry.register('reminder', 'Reminder');
113
+
114
+ // 2. Pass it when scheduling
115
+ await AlarmService.scheduleAlarm({
116
+ timestamp: Date.now() + 60_000,
117
+ title: 'Standup',
118
+ presentation: {
119
+ type: 'reminder', // resolves to the 'Reminder' component
120
+ props: { color: '#6200EE' } // forwarded to the screen as componentProps
121
+ },
122
+ });
123
+ ```
124
+
125
+ The native layer maps `presentation` to two internal fields:
126
+
127
+ | Internal field | Type | Default | Description |
128
+ |---|---|---|---|
129
+ | `componentName` | `string` | `"AlarmRing"` | Registered `AppRegistry` component name shown when the alarm rings. |
130
+ | `componentProps` / `componentPropsJSON` | `object` / `string` | `{}` | Key-value props forwarded to the ring screen on **both platforms**. On Android they arrive as top-level bundle extras and a nested `componentProps` map; on iOS as a parsed `[String: Any]` dictionary. |
131
+
68
132
  ### Direct module access
69
133
 
70
134
  ```ts
@@ -173,15 +237,53 @@ type AlarmConfig = {
173
237
  timestamp: number; // Time in milliseconds (Date.now() format)
174
238
  title: string; // Alarm title/label
175
239
  body?: string; // Alarm description
240
+ image?: string; // file:// or https:// image URI for ring screen/notifications
176
241
  sound?: string; // 'default' or custom sound file name
177
242
  vibrate?: boolean; // Enable vibration (Android only)
178
243
  snoozeEnabled?: boolean; // Allow snoozing
179
244
  snoozeMinutes?: number; // Snooze duration in minutes
180
245
  repeat?: AlarmRepeat; // Repeat configuration
181
246
  data?: string; // Custom JSON string payload
247
+ presentation?: AlarmPresentation; // Custom ring screen & props
248
+ };
249
+
250
+ type AlarmPresentation = {
251
+ /** Registered presentation type (key passed to AlarmRegistry.register). */
252
+ type?: string;
253
+ /**
254
+ * Props forwarded to the ring screen component.
255
+ * Available as `componentProps` on both Android and iOS.
256
+ */
257
+ props?: Record<string, unknown>;
182
258
  };
183
259
  ```
184
260
 
261
+ Pass a URI string for `image`; for example:
262
+
263
+ ```ts
264
+ await AlarmService.scheduleAlarm({
265
+ timestamp: Date.now() + 60_000,
266
+ title: 'Appointment',
267
+ image: 'https://example.com/appointment.jpg',
268
+ });
269
+ ```
270
+
271
+ When provided, `image` is returned with queried alarms and alarm events, passed to
272
+ the registered ring screen as an initial prop, and used for the native notification
273
+ image where supported. Android accepts `https://`, `file://`, and `content://` URIs.
274
+ iOS notification attachments accept `https://` and `file://` URIs; the iOS 26+
275
+ AlarmKit system alert itself does not display custom images, but the image URI is
276
+ available to the React Native ring screen when it opens. Render it in that screen
277
+ with React Native's `Image` component and `source={{ uri: image }}`.
278
+
279
+ > **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:
280
+ >
281
+ > | Field | Type | Default | Description |
282
+ > |---|---|---|---|
283
+ > | `componentName` | `string` | `"AlarmRing"` | The `AppRegistry` name of the component shown at ring time. Derived from `AlarmRegistry.resolve(presentation.type)`. |
284
+ > | `componentProps` | `object` | `{}` | Parsed props object (from `presentation.props`). Available on both platforms at ring time as `componentProps`. |
285
+ > | `componentPropsJSON` | `string` | `"{}"` | JSON-serialised representation of `componentProps`, persisted alongside the alarm so props survive a device reboot. |
286
+
185
287
  #### `AlarmRepeat`
186
288
  Repeat configuration for recurring alarms.
187
289
 
@@ -202,6 +304,7 @@ type Alarm = {
202
304
  timestamp: number;
203
305
  title: string;
204
306
  body: string;
307
+ image?: string;
205
308
  sound: string;
206
309
  vibrate: boolean;
207
310
  snoozeEnabled: boolean;
@@ -433,27 +536,99 @@ For issues and questions, please visit:
433
536
 
434
537
  The library supports completely custom alarm UI screens with custom data payloads.
435
538
 
436
- ### Basic Custom Screen Example
539
+ ### Using `componentName` and `componentProps`
540
+
541
+ The `presentation` field lets you choose **which registered component** is displayed and pass **arbitrary props** directly to it — without encoding everything inside a JSON `data` string.
542
+
543
+ #### 1. Register your screens
544
+
545
+ ```js
546
+ // index.js
547
+ import { AppRegistry } from 'react-native';
548
+ import { AlarmRingScreen } from './src/AlarmRingScreen';
549
+ import { MeetingAlarmScreen } from './src/MeetingAlarmScreen';
550
+
551
+ AppRegistry.registerComponent('AlarmRing', () => AlarmRingScreen); // default fallback
552
+ AppRegistry.registerComponent('MeetingAlarm', () => MeetingAlarmScreen); // custom
553
+ ```
554
+
555
+ #### 2. Map a key to the component name
556
+
557
+ ```ts
558
+ import { AlarmRegistry } from 'react-native-alarms';
559
+
560
+ AlarmRegistry.register('meeting', 'MeetingAlarm');
561
+ ```
562
+
563
+ #### 3. Schedule with `presentation`
437
564
 
438
565
  ```typescript
439
- import { NativeModules } from 'react-native';
440
566
  import { AlarmService } from 'react-native-alarms/service';
441
567
 
442
- export function MyCustomAlarmScreen({ alarmId, title, data }) {
443
- const customData = JSON.parse(data || '{}');
568
+ await AlarmService.scheduleAlarm({
569
+ timestamp: Date.now() + 5 * 60 * 1000,
570
+ title: 'Team standup',
571
+ presentation: {
572
+ type: 'meeting', // resolves to 'MeetingAlarm'
573
+ props: {
574
+ roomName: 'Zoom Room A',
575
+ meetingUrl: 'https://zoom.us/j/123',
576
+ accentColor: '#6200EE',
577
+ },
578
+ },
579
+ });
580
+ ```
581
+
582
+ #### 4. Receive `componentProps` in your screen
583
+
584
+ The props passed via `presentation.props` are forwarded to the ring screen on both platforms:
585
+
586
+ ```typescript
587
+ // MeetingAlarmScreen.tsx
588
+ type Props = {
589
+ alarmId: string;
590
+ title: string;
591
+ snoozeEnabled: boolean;
592
+ snoozeMinutes: number;
593
+ data?: string;
594
+ // componentProps fields arrive as top-level props:
595
+ roomName?: string;
596
+ meetingUrl?: string;
597
+ accentColor?: string;
598
+ };
444
599
 
600
+ export function MeetingAlarmScreen({
601
+ alarmId,
602
+ title,
603
+ snoozeEnabled,
604
+ snoozeMinutes,
605
+ roomName,
606
+ meetingUrl,
607
+ accentColor = '#007AFF',
608
+ }: Props) {
445
609
  return (
446
- <View style={{ backgroundColor: customData.color }}>
447
- <Text>{title}</Text>
610
+ <View style={{ flex: 1, backgroundColor: accentColor }}>
611
+ <Text style={{ fontSize: 28, color: '#fff' }}>{title}</Text>
612
+ <Text style={{ color: '#fff' }}>{roomName}</Text>
613
+
614
+ {snoozeEnabled && (
615
+ <TouchableOpacity onPress={() => AlarmService.snoozeAlarm(alarmId, snoozeMinutes)}>
616
+ <Text>Snooze {snoozeMinutes} min</Text>
617
+ </TouchableOpacity>
618
+ )}
448
619
  <TouchableOpacity onPress={() => AlarmService.stopAlarm(alarmId)}>
449
- <Text>Stop</Text>
620
+ <Text>Dismiss</Text>
450
621
  </TouchableOpacity>
451
622
  </View>
452
623
  );
453
624
  }
454
625
  ```
455
626
 
456
- ### Passing Custom Data
627
+ > **Platform behaviour**
628
+ > - **Android**: `componentProps` entries are flattened into the launch `Bundle` alongside standard fields (`alarmId`, `title`, etc.), and also available as a nested `componentProps` bundle. The `AlarmRingActivity` uses `componentName` to resolve which registered React root to display.
629
+ > - **iOS**: `componentProps` (parsed from `componentPropsJSON`) is merged into the surface's initial properties passed to the React component.
630
+
631
+ ### Passing Custom Data (legacy approach)
457
632
 
458
633
  ```typescript
459
634
  await AlarmService.scheduleAlarm({
@@ -468,4 +643,3 @@ await AlarmService.scheduleAlarm({
468
643
  ```
469
644
 
470
645
  **Full Guide:** See [CUSTOM_UI_GUIDE.md](./CUSTOM_UI_GUIDE.md) for complete examples and best practices.
471
-
@@ -3,6 +3,7 @@ package com.alarm
3
3
  import android.os.Bundle
4
4
  import com.facebook.react.bridge.Arguments
5
5
  import com.facebook.react.bridge.ReadableMap
6
+ import com.facebook.react.bridge.WritableArray
6
7
  import com.facebook.react.bridge.WritableMap
7
8
  import org.json.JSONArray
8
9
  import org.json.JSONObject
@@ -13,6 +14,7 @@ data class AlarmData(
13
14
  val timestamp: Long,
14
15
  val title: String,
15
16
  val body: String,
17
+ val image: String,
16
18
  val sound: String,
17
19
  val vibrate: Boolean,
18
20
  val snoozeEnabled: Boolean,
@@ -21,8 +23,29 @@ data class AlarmData(
21
23
  val weekdays: List<Int>,
22
24
  val intervalMs: Long,
23
25
  val data: String,
24
- var enabled: Boolean = true
26
+ var enabled: Boolean = true,
27
+ val componentName: String = "AlarmRing",
28
+ val componentPropsJSON: String = "{}"
25
29
  ) {
30
+ val componentProps: Map<String, Any>
31
+ get() {
32
+ return try {
33
+ val obj = JSONObject(componentPropsJSON)
34
+ val map = mutableMapOf<String, Any>()
35
+ val keys = obj.keys()
36
+ while (keys.hasNext()) {
37
+ val key = keys.next()
38
+ val value = obj.get(key)
39
+ if (value != JSONObject.NULL) {
40
+ map[key] = value
41
+ }
42
+ }
43
+ map
44
+ } catch (_: Exception) {
45
+ emptyMap()
46
+ }
47
+ }
48
+
26
49
  fun nextTrigger(from: Long = System.currentTimeMillis()): Long {
27
50
  if (!enabled) return 0
28
51
  return when (repeatType) {
@@ -55,10 +78,13 @@ data class AlarmData(
55
78
 
56
79
  fun toJson(): String = JSONObject().apply {
57
80
  put("id", id); put("timestamp", timestamp); put("title", title); put("body", body)
81
+ put("image", image)
58
82
  put("sound", sound); put("vibrate", vibrate); put("snoozeEnabled", snoozeEnabled)
59
83
  put("snoozeMinutes", snoozeMinutes); put("repeatType", repeatType)
60
84
  put("weekdays", JSONArray(weekdays)); put("intervalMs", intervalMs)
61
85
  put("data", data); put("enabled", enabled)
86
+ put("componentName", componentName)
87
+ put("componentPropsJSON", componentPropsJSON)
62
88
  }.toString()
63
89
 
64
90
  fun toWritableMap(): WritableMap = Arguments.createMap().apply {
@@ -66,6 +92,7 @@ data class AlarmData(
66
92
  putDouble("timestamp", timestamp.toDouble())
67
93
  putString("title", title)
68
94
  putString("body", body)
95
+ putString("image", image)
69
96
  putString("sound", sound)
70
97
  putBoolean("vibrate", vibrate)
71
98
  putBoolean("snoozeEnabled", snoozeEnabled)
@@ -78,6 +105,13 @@ data class AlarmData(
78
105
  putString("data", data)
79
106
  putBoolean("enabled", enabled)
80
107
  putDouble("nextTriggerAt", nextTrigger().toDouble())
108
+ putString("componentName", componentName)
109
+ putString("componentPropsJSON", componentPropsJSON)
110
+ try {
111
+ putMap("componentProps", jsonToWritableMap(JSONObject(componentPropsJSON)))
112
+ } catch (_: Exception) {
113
+ putMap("componentProps", Arguments.createMap())
114
+ }
81
115
  }
82
116
 
83
117
  /** Initial props for the React ring screen hosted by [AlarmRingActivity]. */
@@ -86,22 +120,75 @@ data class AlarmData(
86
120
  putDouble("timestamp", timestamp.toDouble())
87
121
  putString("title", title)
88
122
  putString("body", body)
123
+ putString("image", image)
89
124
  putBoolean("snoozeEnabled", snoozeEnabled)
90
125
  putInt("snoozeMinutes", snoozeMinutes)
91
126
  putString("data", data)
127
+ putString("componentName", componentName)
128
+ putString("componentPropsJSON", componentPropsJSON)
129
+ try {
130
+ val propsObj = JSONObject(componentPropsJSON)
131
+ val propsBundle = jsonToBundle(propsObj)
132
+ putBundle("componentProps", propsBundle)
133
+ for (key in propsBundle.keySet()) {
134
+ if (!containsKey(key)) {
135
+ val v = propsBundle.get(key)
136
+ when (v) {
137
+ is String -> putString(key, v)
138
+ is Boolean -> putBoolean(key, v)
139
+ is Int -> putInt(key, v)
140
+ is Long -> putLong(key, v)
141
+ is Double -> putDouble(key, v)
142
+ is Bundle -> putBundle(key, v)
143
+ }
144
+ }
145
+ }
146
+ } catch (_: Exception) {}
147
+ }
148
+
149
+ private fun jsonToBundle(jsonObject: JSONObject): Bundle {
150
+ val bundle = Bundle()
151
+ val iter = jsonObject.keys()
152
+ while (iter.hasNext()) {
153
+ val key = iter.next()
154
+ when (val value = jsonObject.opt(key)) {
155
+ is JSONObject -> bundle.putBundle(key, jsonToBundle(value))
156
+ is JSONArray -> bundle.putString(key, value.toString())
157
+ is Boolean -> bundle.putBoolean(key, value)
158
+ is Int -> bundle.putInt(key, value)
159
+ is Long -> bundle.putLong(key, value)
160
+ is Double -> bundle.putDouble(key, value)
161
+ is String -> bundle.putString(key, value)
162
+ else -> if (value != null && value != JSONObject.NULL) {
163
+ bundle.putString(key, value.toString())
164
+ }
165
+ }
166
+ }
167
+ return bundle
92
168
  }
93
169
 
94
170
  companion object {
95
171
  fun fromJson(s: String): AlarmData {
96
172
  val o = JSONObject(s)
97
173
  val wd = o.optJSONArray("weekdays")
174
+ val compName = o.optString("componentName", "AlarmRing").trim()
98
175
  return AlarmData(
99
- o.getString("id"), o.getLong("timestamp"), o.optString("title"), o.optString("body"),
100
- o.optString("sound", "default"), o.optBoolean("vibrate", true),
101
- o.optBoolean("snoozeEnabled", true), o.optInt("snoozeMinutes", 5),
102
- o.optString("repeatType", "none"),
103
- if (wd == null) emptyList() else (0 until wd.length()).map { wd.getInt(it) },
104
- o.optLong("intervalMs", 0), o.optString("data"), o.optBoolean("enabled", true)
176
+ id = o.getString("id"),
177
+ timestamp = o.getLong("timestamp"),
178
+ title = o.optString("title"),
179
+ body = o.optString("body"),
180
+ image = o.optString("image"),
181
+ sound = o.optString("sound", "default"),
182
+ vibrate = o.optBoolean("vibrate", true),
183
+ snoozeEnabled = o.optBoolean("snoozeEnabled", true),
184
+ snoozeMinutes = o.optInt("snoozeMinutes", 5),
185
+ repeatType = o.optString("repeatType", "none"),
186
+ weekdays = if (wd == null) emptyList() else (0 until wd.length()).map { wd.getInt(it) },
187
+ intervalMs = o.optLong("intervalMs", 0),
188
+ data = o.optString("data"),
189
+ enabled = o.optBoolean("enabled", true),
190
+ componentName = if (compName.isEmpty()) "AlarmRing" else compName,
191
+ componentPropsJSON = o.optString("componentPropsJSON", "{}").ifBlank { "{}" }
105
192
  )
106
193
  }
107
194
 
@@ -109,11 +196,30 @@ data class AlarmData(
109
196
  val r = if (m.hasKey("repeat") && !m.isNull("repeat")) m.getMap("repeat") else null
110
197
  val wd = r?.takeIf { it.hasKey("weekdays") }?.getArray("weekdays")
111
198
  ?.let { a -> (0 until a.size()).map { a.getInt(it) } } ?: emptyList()
199
+
200
+ val componentPropsStr = when {
201
+ m.hasKey("componentPropsJSON") && !m.isNull("componentPropsJSON") ->
202
+ m.getString("componentPropsJSON") ?: "{}"
203
+ m.hasKey("componentProps") && !m.isNull("componentProps") -> {
204
+ val propsMap = m.getMap("componentProps")
205
+ try {
206
+ val hashMap = propsMap?.toHashMap() ?: emptyMap<String, Any>()
207
+ JSONObject(hashMap).toString()
208
+ } catch (_: Exception) {
209
+ "{}"
210
+ }
211
+ }
212
+ else -> "{}"
213
+ }
214
+
215
+ val compName = m.str("componentName", "AlarmRing").trim()
216
+
112
217
  return AlarmData(
113
218
  id = id,
114
219
  timestamp = m.getDouble("timestamp").toLong(),
115
220
  title = m.str("title"),
116
221
  body = m.str("body"),
222
+ image = m.str("image"),
117
223
  sound = m.str("sound", "default"),
118
224
  vibrate = m.bool("vibrate", true),
119
225
  snoozeEnabled = m.bool("snoozeEnabled", true),
@@ -121,10 +227,56 @@ data class AlarmData(
121
227
  repeatType = r?.str("type", "none") ?: "none",
122
228
  weekdays = wd,
123
229
  intervalMs = if (r != null && r.hasKey("intervalMs")) r.getDouble("intervalMs").toLong() else 0,
124
- data = m.str("data")
230
+ data = m.str("data"),
231
+ componentName = if (compName.isEmpty()) "AlarmRing" else compName,
232
+ componentPropsJSON = componentPropsStr
125
233
  )
126
234
  }
127
235
 
236
+ private fun jsonToWritableMap(jsonObject: JSONObject): WritableMap {
237
+ val map = Arguments.createMap()
238
+ val iter = jsonObject.keys()
239
+ while (iter.hasNext()) {
240
+ val key = iter.next()
241
+ when (val value = jsonObject.opt(key)) {
242
+ is JSONObject -> map.putMap(key, jsonToWritableMap(value))
243
+ is JSONArray -> map.putArray(key, jsonToWritableArray(value))
244
+ is Boolean -> map.putBoolean(key, value)
245
+ is Int -> map.putInt(key, value)
246
+ is Long -> map.putDouble(key, value.toDouble())
247
+ is Double -> map.putDouble(key, value)
248
+ is String -> map.putString(key, value)
249
+ else -> if (value == null || value == JSONObject.NULL) {
250
+ map.putNull(key)
251
+ } else {
252
+ map.putString(key, value.toString())
253
+ }
254
+ }
255
+ }
256
+ return map
257
+ }
258
+
259
+ private fun jsonToWritableArray(jsonArray: JSONArray): WritableArray {
260
+ val array = Arguments.createArray()
261
+ for (i in 0 until jsonArray.length()) {
262
+ when (val value = jsonArray.opt(i)) {
263
+ is JSONObject -> array.pushMap(jsonToWritableMap(value))
264
+ is JSONArray -> array.pushArray(jsonToWritableArray(value))
265
+ is Boolean -> array.pushBoolean(value)
266
+ is Int -> array.pushInt(value)
267
+ is Long -> array.pushDouble(value.toDouble())
268
+ is Double -> array.pushDouble(value)
269
+ is String -> array.pushString(value)
270
+ else -> if (value == null || value == JSONObject.NULL) {
271
+ array.pushNull()
272
+ } else {
273
+ array.pushString(value.toString())
274
+ }
275
+ }
276
+ }
277
+ return array
278
+ }
279
+
128
280
  private fun ReadableMap.str(k: String, d: String = "") =
129
281
  if (hasKey(k) && !isNull(k)) getString(k) ?: d else d
130
282
 
@@ -31,14 +31,14 @@ class AlarmModule(private val ctx: ReactApplicationContext) : ReactContextBaseJa
31
31
  private fun notificationsGranted(): Boolean =
32
32
  if (Build.VERSION.SDK_INT >= 33)
33
33
  ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) ==
34
- PackageManager.PERMISSION_GRANTED
34
+ PackageManager.PERMISSION_GRANTED
35
35
  else NotificationManagerCompat.from(ctx).areNotificationsEnabled()
36
36
 
37
37
  private fun canDrawOverlays(): Boolean = Settings.canDrawOverlays(ctx)
38
38
 
39
39
  private fun canUseFullScreenIntent(): Boolean =
40
40
  Build.VERSION.SDK_INT < 34 ||
41
- ctx.getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
41
+ ctx.getSystemService(NotificationManager::class.java).canUseFullScreenIntent()
42
42
 
43
43
  private fun permissionMap(): WritableMap = Arguments.createMap().apply {
44
44
  val ok = notificationsGranted()
@@ -241,6 +241,7 @@ class AlarmModule(private val ctx: ReactApplicationContext) : ReactContextBaseJa
241
241
  putDouble("timestamp", System.currentTimeMillis().toDouble())
242
242
  putString("title", a.title)
243
243
  putString("body", a.body)
244
+ putString("image", a.image)
244
245
  putString("data", a.data)
245
246
  }
246
247
  ctx.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java).emit(eventName, map)
@@ -18,7 +18,8 @@ import java.lang.ref.WeakReference
18
18
  */
19
19
  class AlarmRingActivity : ReactActivity() {
20
20
 
21
- override fun getMainComponentName(): String = COMPONENT_NAME
21
+ override fun getMainComponentName(): String =
22
+ AlarmService.current?.componentName?.takeIf { it.isNotBlank() } ?: COMPONENT_NAME
22
23
 
23
24
  override fun createReactActivityDelegate(): ReactActivityDelegate =
24
25
  object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) {
@@ -45,7 +46,7 @@ class AlarmRingActivity : ReactActivity() {
45
46
  @Suppress("DEPRECATION")
46
47
  window.addFlags(
47
48
  WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
48
- WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
49
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
49
50
  )
50
51
  }
51
52
  window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
@@ -8,6 +8,8 @@ import android.app.Service
8
8
  import android.content.Context
9
9
  import android.content.Intent
10
10
  import android.content.pm.ServiceInfo
11
+ import android.graphics.Bitmap
12
+ import android.graphics.BitmapFactory
11
13
  import android.media.AudioAttributes
12
14
  import android.media.MediaPlayer
13
15
  import android.media.RingtoneManager
@@ -22,6 +24,9 @@ import android.os.Vibrator
22
24
  import android.util.Log
23
25
  import androidx.core.app.NotificationCompat
24
26
  import androidx.core.app.ServiceCompat
27
+ import java.io.InputStream
28
+ import java.net.URL
29
+ import java.util.concurrent.Executors
25
30
 
26
31
  /**
27
32
  * Foreground service that actually rings: looping sound, vibration, a partial wake
@@ -39,6 +44,7 @@ class AlarmService : Service() {
39
44
  private var player: MediaPlayer? = null
40
45
  private var vibrator: Vibrator? = null
41
46
  private var wakeLock: PowerManager.WakeLock? = null
47
+ private val imageExecutor = Executors.newSingleThreadExecutor()
42
48
 
43
49
  override fun onBind(intent: Intent?): IBinder? = null
44
50
 
@@ -70,6 +76,7 @@ class AlarmService : Service() {
70
76
  Log.i(TAG, "Ringing alarm ${alarm.id}")
71
77
 
72
78
  startInForeground(buildNotification(alarm))
79
+ loadNotificationImage(alarm)
73
80
  acquireWakeLock()
74
81
  startSound(alarm)
75
82
  if (alarm.vibrate) startVibration()
@@ -139,6 +146,7 @@ class AlarmService : Service() {
139
146
 
140
147
  override fun onDestroy() {
141
148
  stopRinging()
149
+ imageExecutor.shutdownNow()
142
150
  super.onDestroy()
143
151
  }
144
152
 
@@ -217,7 +225,7 @@ class AlarmService : Service() {
217
225
  .build()
218
226
  }
219
227
 
220
- private fun buildNotification(alarm: AlarmData): Notification {
228
+ private fun buildNotification(alarm: AlarmData, image: Bitmap? = null): Notification {
221
229
  ensureChannel()
222
230
  val ringIntent = Intent(this, AlarmRingActivity::class.java)
223
231
  .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
@@ -235,12 +243,58 @@ class AlarmService : Service() {
235
243
  .setFullScreenIntent(fullScreen, true)
236
244
  .setContentIntent(fullScreen)
237
245
  .addAction(0, "Stop", commandPI(ACTION_DISMISS, 2))
246
+ if (image != null) {
247
+ b.setStyle(
248
+ NotificationCompat.BigPictureStyle()
249
+ .bigPicture(image)
250
+ .setBigContentTitle(alarm.title.ifBlank { "Alarm" })
251
+ .setSummaryText(alarm.body.ifBlank { "Alarm is ringing" })
252
+ )
253
+ }
238
254
  if (alarm.snoozeEnabled) {
239
255
  b.addAction(0, "Snooze", commandPI(ACTION_SNOOZE, 3))
240
256
  }
241
257
  return b.build()
242
258
  }
243
259
 
260
+ private fun loadNotificationImage(alarm: AlarmData) {
261
+ if (alarm.image.isBlank()) return
262
+ imageExecutor.execute {
263
+ try {
264
+ val bitmap = decodeImage(Uri.parse(alarm.image)) ?: return@execute
265
+ handler.post {
266
+ if (current?.id == alarm.id) {
267
+ ensureChannel().notify(NOTIFICATION_ID, buildNotification(alarm, bitmap))
268
+ }
269
+ }
270
+ } catch (e: Exception) {
271
+ Log.w(TAG, "Could not load notification image for alarm ${alarm.id}", e)
272
+ }
273
+ }
274
+ }
275
+
276
+ private fun decodeImage(uri: Uri): Bitmap? {
277
+ fun openStream(): InputStream? = when (uri.scheme?.lowercase()) {
278
+ "https" -> URL(uri.toString()).openConnection().apply {
279
+ connectTimeout = 5_000
280
+ readTimeout = 5_000
281
+ }.getInputStream()
282
+ "file", "content", "android.resource" -> contentResolver.openInputStream(uri)
283
+ else -> throw IllegalArgumentException("Unsupported alarm image URI: $uri")
284
+ }
285
+
286
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
287
+ openStream()?.use { BitmapFactory.decodeStream(it, null, bounds) }
288
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
289
+
290
+ var sampleSize = 1
291
+ while (bounds.outWidth / sampleSize > 1024 || bounds.outHeight / sampleSize > 1024) {
292
+ sampleSize *= 2
293
+ }
294
+ val options = BitmapFactory.Options().apply { inSampleSize = sampleSize }
295
+ return openStream()?.use { BitmapFactory.decodeStream(it, null, options) }
296
+ }
297
+
244
298
  private fun commandPI(action: String, code: Int): PendingIntent =
245
299
  PendingIntent.getService(
246
300
  this, code, Intent(this, AlarmService::class.java).setAction(action),