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/AlarmService.ts +289 -89
- package/README.md +186 -12
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmData.kt +160 -8
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmModule.kt +3 -2
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmRingActivity.kt +3 -2
- package/android/app/src/main/java/com/reactnativealarm/alarm/AlarmService.kt +55 -1
- package/android/local.properties +8 -0
- package/android-lib/src/main/AndroidManifest.xml +1 -0
- package/index.ts +7 -5
- package/ios/.xcode.env.local +1 -1
- package/ios/ReactNativeAlarm/Info.plist +2 -2
- package/ios/ReactNativeAlarm.xcodeproj/project.pbxproj +8 -0
- package/ios/alarm/AlarmData.swift +23 -6
- package/ios/alarm/AlarmKitBackend.swift +43 -40
- package/ios/alarm/AlarmModule.swift +69 -64
- package/ios/alarm/AlarmRingPresenter.swift +26 -19
- package/ios/alarm/AlarmStore.swift +9 -9
- package/ios/alarm/NotificationBackend.swift +113 -64
- package/package.json +4 -4
- package/specs/NativeAlarm.ts +127 -74
- package/src/types/index.ts +266 -0
- package/src/utils/AlarmRegistry.ts +57 -0
- package/src/utils/index.ts +1 -0
- package/.bundle/config +0 -2
- package/.husky/commit-msg +0 -1
- package/.husky/pre-commit +0 -0
- package/AlarmService.d.ts +0 -117
- package/commitlint.config.mjs +0 -1
- package/index.d.ts +0 -20
- package/src/assets/recording.mp4 +0 -0
- package/src/assets/sample-1.png +0 -0
- package/src/assets/sample-2.png +0 -0
- package/src/assets/sample-3.png +0 -0
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# React Native Alarms 🔔
|
|
2
2
|
|
|
3
|
-
A
|
|
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
|
[](https://badge.fury.io/js/react-native-alarms)
|
|
6
11
|
[](https://opensource.org/licenses/MIT)
|
|
@@ -39,7 +44,7 @@ yarn add react-native-alarms
|
|
|
39
44
|
|
|
40
45
|
### Native Module Linking
|
|
41
46
|
|
|
42
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
443
|
-
|
|
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:
|
|
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>
|
|
620
|
+
<Text>Dismiss</Text>
|
|
450
621
|
</TouchableOpacity>
|
|
451
622
|
</View>
|
|
452
623
|
);
|
|
453
624
|
}
|
|
454
625
|
```
|
|
455
626
|
|
|
456
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
o.optString("
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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),
|