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,24 @@
|
|
|
1
|
+
package com.alarm
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.BaseReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
class AlarmPackage : BaseReactPackage() {
|
|
10
|
+
override fun getModule(name: String, ctx: ReactApplicationContext): NativeModule? =
|
|
11
|
+
if (name == AlarmModule.NAME) AlarmModule(ctx) else null
|
|
12
|
+
|
|
13
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
14
|
+
mapOf(
|
|
15
|
+
AlarmModule.NAME to ReactModuleInfo(
|
|
16
|
+
AlarmModule.NAME, AlarmModule.NAME,
|
|
17
|
+
false, // canOverrideExistingModule
|
|
18
|
+
false, // needsEagerInit
|
|
19
|
+
false, // isCxxModule
|
|
20
|
+
false // isTurboModule
|
|
21
|
+
)
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
package com.alarm
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.util.Log
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AlarmManager fires here at the exact trigger time. The receiver only gets ~10s of
|
|
10
|
+
* guaranteed execution, so it updates the store and hands off to [AlarmService]
|
|
11
|
+
* immediately. The service does the actual ringing, screen wake and UI.
|
|
12
|
+
*/
|
|
13
|
+
class AlarmReceiver : BroadcastReceiver() {
|
|
14
|
+
companion object {
|
|
15
|
+
private const val TAG = "AlarmReceiver"
|
|
16
|
+
const val ACTION_FIRE = "com.alarm.FIRE"
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override fun onReceive(ctx: Context, intent: Intent) {
|
|
20
|
+
if (intent.action != ACTION_FIRE) return
|
|
21
|
+
val id = intent.getStringExtra(AlarmScheduler.EXTRA_ID) ?: return
|
|
22
|
+
val alarm = AlarmStore.get(ctx, id)
|
|
23
|
+
if (alarm == null) {
|
|
24
|
+
Log.w(TAG, "Alarm $id fired but is no longer stored")
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
Log.i(TAG, "Alarm $id fired (snooze=${intent.getBooleanExtra(AlarmScheduler.EXTRA_SNOOZE, false)})")
|
|
28
|
+
|
|
29
|
+
if (!intent.getBooleanExtra(AlarmScheduler.EXTRA_SNOOZE, false)) {
|
|
30
|
+
if (alarm.repeatType == "none") {
|
|
31
|
+
alarm.enabled = false
|
|
32
|
+
AlarmStore.save(ctx, alarm)
|
|
33
|
+
} else {
|
|
34
|
+
// Repeating: arm the next occurrence right away so it is guaranteed even
|
|
35
|
+
// if the user force-stops the ringing screen.
|
|
36
|
+
runCatching { AlarmScheduler.schedule(ctx, alarm) }
|
|
37
|
+
.onFailure { Log.e(TAG, "Could not re-arm repeating alarm $id", it) }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
AlarmService.start(ctx, id)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
package com.alarm
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.os.Build
|
|
6
|
+
import android.os.Bundle
|
|
7
|
+
import android.view.WindowManager
|
|
8
|
+
import com.facebook.react.ReactActivity
|
|
9
|
+
import com.facebook.react.ReactActivityDelegate
|
|
10
|
+
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
|
|
11
|
+
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
|
12
|
+
import java.lang.ref.WeakReference
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Full-screen ringing UI shown over the lock screen (or on top of the app when it is
|
|
16
|
+
* open). Hosts the React component registered as [COMPONENT_NAME] and passes the
|
|
17
|
+
* ringing alarm as its initial props. Mirrors Upupup's RingActivity.
|
|
18
|
+
*/
|
|
19
|
+
class AlarmRingActivity : ReactActivity() {
|
|
20
|
+
|
|
21
|
+
override fun getMainComponentName(): String = COMPONENT_NAME
|
|
22
|
+
|
|
23
|
+
override fun createReactActivityDelegate(): ReactActivityDelegate =
|
|
24
|
+
object : DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) {
|
|
25
|
+
override fun getLaunchOptions(): Bundle? = AlarmService.current?.toBundle()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override fun onCreate(savedInstanceState: Bundle?) {
|
|
29
|
+
// Stale launch (alarm already stopped): don't even spin up the React root.
|
|
30
|
+
if (AlarmService.current == null) {
|
|
31
|
+
super.onCreate(null)
|
|
32
|
+
finish()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
instance = WeakReference(this)
|
|
36
|
+
super.onCreate(savedInstanceState)
|
|
37
|
+
showOverLockScreen()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private fun showOverLockScreen() {
|
|
41
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
|
42
|
+
setShowWhenLocked(true)
|
|
43
|
+
setTurnScreenOn(true)
|
|
44
|
+
} else {
|
|
45
|
+
@Suppress("DEPRECATION")
|
|
46
|
+
window.addFlags(
|
|
47
|
+
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
|
48
|
+
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The only ways out are Stop and Snooze: swallow the back button. */
|
|
55
|
+
override fun invokeDefaultOnBackPressed() { /* consume */ }
|
|
56
|
+
|
|
57
|
+
override fun onDestroy() {
|
|
58
|
+
if (instance?.get() === this) instance = null
|
|
59
|
+
super.onDestroy()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
companion object {
|
|
63
|
+
const val COMPONENT_NAME = "AlarmRing"
|
|
64
|
+
|
|
65
|
+
private var instance: WeakReference<AlarmRingActivity>? = null
|
|
66
|
+
|
|
67
|
+
val isOpen: Boolean get() = instance?.get() != null
|
|
68
|
+
|
|
69
|
+
/** Called by [AlarmService] when ringing ends for any reason. */
|
|
70
|
+
fun finishIfOpen() {
|
|
71
|
+
instance?.get()?.finish()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Brings the ring screen up when an alarm is ringing and the user opens the app
|
|
76
|
+
* (the full-screen intent only fires when the screen is off/locked).
|
|
77
|
+
*/
|
|
78
|
+
fun openIfRinging(activity: Activity) {
|
|
79
|
+
if (AlarmService.current != null && !isOpen) {
|
|
80
|
+
activity.startActivity(
|
|
81
|
+
Intent(activity, AlarmRingActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
package com.alarm
|
|
2
|
+
|
|
3
|
+
import android.app.AlarmManager
|
|
4
|
+
import android.app.PendingIntent
|
|
5
|
+
import android.content.Context
|
|
6
|
+
import android.content.Intent
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import android.util.Log
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers alarms with [AlarmManager.setAlarmClock]: exact trigger, exempt from Doze
|
|
12
|
+
* and battery optimisations, and the system shows the alarm-clock status-bar icon.
|
|
13
|
+
*/
|
|
14
|
+
object AlarmScheduler {
|
|
15
|
+
private const val TAG = "AlarmScheduler"
|
|
16
|
+
const val EXTRA_ID = "alarmId"
|
|
17
|
+
const val EXTRA_SNOOZE = "snooze"
|
|
18
|
+
|
|
19
|
+
private fun am(ctx: Context) = ctx.getSystemService(AlarmManager::class.java)
|
|
20
|
+
|
|
21
|
+
fun canScheduleExact(ctx: Context) =
|
|
22
|
+
Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am(ctx).canScheduleExactAlarms()
|
|
23
|
+
|
|
24
|
+
/** Re-syncs every enabled alarm with AlarmManager. Idempotent. */
|
|
25
|
+
fun scheduleAll(ctx: Context) {
|
|
26
|
+
AlarmStore.all(ctx).forEach { a ->
|
|
27
|
+
if (a.enabled) runCatching { schedule(ctx, a) }
|
|
28
|
+
.onFailure { Log.e(TAG, "Could not re-arm ${a.id}", it) }
|
|
29
|
+
else cancel(ctx, a.id)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fun schedule(ctx: Context, a: AlarmData) {
|
|
34
|
+
val at = a.nextTrigger()
|
|
35
|
+
if (at > 0) setExact(ctx, at, firePI(ctx, a.id, false))
|
|
36
|
+
else Log.i(TAG, "Alarm ${a.id} has no future trigger; not scheduled")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
fun scheduleSnooze(ctx: Context, a: AlarmData, minutes: Int) =
|
|
40
|
+
setExact(ctx, System.currentTimeMillis() + minutes * 60_000L, firePI(ctx, a.id, true))
|
|
41
|
+
|
|
42
|
+
fun cancel(ctx: Context, id: String) {
|
|
43
|
+
am(ctx).cancel(firePI(ctx, id, false))
|
|
44
|
+
cancelSnooze(ctx, id)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fun cancelSnooze(ctx: Context, id: String) {
|
|
48
|
+
am(ctx).cancel(firePI(ctx, id, true))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private fun setExact(ctx: Context, at: Long, pi: PendingIntent) {
|
|
52
|
+
if (!canScheduleExact(ctx)) {
|
|
53
|
+
throw SecurityException("Exact alarm permission (SCHEDULE_EXACT_ALARM) not granted")
|
|
54
|
+
}
|
|
55
|
+
// Tapping the status-bar alarm icon opens the app.
|
|
56
|
+
val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
|
|
57
|
+
val show = launch?.let {
|
|
58
|
+
PendingIntent.getActivity(ctx, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
|
59
|
+
}
|
|
60
|
+
am(ctx).setAlarmClock(AlarmManager.AlarmClockInfo(at, show), pi)
|
|
61
|
+
Log.i(TAG, "Alarm set for ${java.util.Date(at)}")
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private fun firePI(ctx: Context, id: String, snooze: Boolean): PendingIntent {
|
|
65
|
+
val i = Intent(ctx, AlarmReceiver::class.java)
|
|
66
|
+
.setAction(AlarmReceiver.ACTION_FIRE)
|
|
67
|
+
.putExtra(EXTRA_ID, id)
|
|
68
|
+
.putExtra(EXTRA_SNOOZE, snooze)
|
|
69
|
+
return PendingIntent.getBroadcast(
|
|
70
|
+
ctx, (id + if (snooze) "_s" else "").hashCode(), i,
|
|
71
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
package com.alarm
|
|
2
|
+
|
|
3
|
+
import android.app.Notification
|
|
4
|
+
import android.app.NotificationChannel
|
|
5
|
+
import android.app.NotificationManager
|
|
6
|
+
import android.app.PendingIntent
|
|
7
|
+
import android.app.Service
|
|
8
|
+
import android.content.Context
|
|
9
|
+
import android.content.Intent
|
|
10
|
+
import android.content.pm.ServiceInfo
|
|
11
|
+
import android.media.AudioAttributes
|
|
12
|
+
import android.media.MediaPlayer
|
|
13
|
+
import android.media.RingtoneManager
|
|
14
|
+
import android.net.Uri
|
|
15
|
+
import android.os.Build
|
|
16
|
+
import android.os.Handler
|
|
17
|
+
import android.os.IBinder
|
|
18
|
+
import android.os.Looper
|
|
19
|
+
import android.os.PowerManager
|
|
20
|
+
import android.os.VibrationEffect
|
|
21
|
+
import android.os.Vibrator
|
|
22
|
+
import android.util.Log
|
|
23
|
+
import androidx.core.app.NotificationCompat
|
|
24
|
+
import androidx.core.app.ServiceCompat
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Foreground service that actually rings: looping sound, vibration, a partial wake
|
|
28
|
+
* lock and a full-screen notification that pops [AlarmRingActivity] over the lock
|
|
29
|
+
* screen. Same architecture as Upupup's AlarmService.
|
|
30
|
+
*
|
|
31
|
+
* Why a service and not just a notification from the receiver: a BroadcastReceiver
|
|
32
|
+
* only gets ~10s of execution and the process can be killed right after. A
|
|
33
|
+
* foreground service keeps the process alive (JS runtime included once started),
|
|
34
|
+
* keeps the sound looping and is what lets a killed app ring reliably.
|
|
35
|
+
*/
|
|
36
|
+
class AlarmService : Service() {
|
|
37
|
+
|
|
38
|
+
private val handler = Handler(Looper.getMainLooper())
|
|
39
|
+
private var player: MediaPlayer? = null
|
|
40
|
+
private var vibrator: Vibrator? = null
|
|
41
|
+
private var wakeLock: PowerManager.WakeLock? = null
|
|
42
|
+
|
|
43
|
+
override fun onBind(intent: Intent?): IBinder? = null
|
|
44
|
+
|
|
45
|
+
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
46
|
+
when (intent?.action) {
|
|
47
|
+
ACTION_SNOOZE -> {
|
|
48
|
+
snooze(intent.getIntExtra(EXTRA_SNOOZE_MINUTES, -1))
|
|
49
|
+
return START_NOT_STICKY
|
|
50
|
+
}
|
|
51
|
+
ACTION_DISMISS -> {
|
|
52
|
+
dismiss()
|
|
53
|
+
return START_NOT_STICKY
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
val id = intent?.getStringExtra(AlarmScheduler.EXTRA_ID)
|
|
58
|
+
val alarm = id?.let { AlarmStore.get(this, it) }
|
|
59
|
+
if (alarm == null) {
|
|
60
|
+
Log.w(TAG, "Started without a known alarm (id=$id); stopping")
|
|
61
|
+
// Must still enter the foreground briefly: we were started with
|
|
62
|
+
// startForegroundService() and the system expects startForeground().
|
|
63
|
+
runCatching { startInForeground(placeholderNotification()) }
|
|
64
|
+
stopSelf()
|
|
65
|
+
return START_NOT_STICKY
|
|
66
|
+
}
|
|
67
|
+
if (current != null) stopRinging() // a second alarm fired while one was ringing
|
|
68
|
+
|
|
69
|
+
current = alarm
|
|
70
|
+
Log.i(TAG, "Ringing alarm ${alarm.id}")
|
|
71
|
+
|
|
72
|
+
startInForeground(buildNotification(alarm))
|
|
73
|
+
acquireWakeLock()
|
|
74
|
+
startSound(alarm)
|
|
75
|
+
if (alarm.vibrate) startVibration()
|
|
76
|
+
|
|
77
|
+
// The full-screen intent only fires when the screen is off/locked and on some
|
|
78
|
+
// OEMs (Xiaomi, Oppo, Vivo...) it is unreliable even then. Launching the ring
|
|
79
|
+
// activity directly works whenever the app has "display over other apps"
|
|
80
|
+
// (SYSTEM_ALERT_WINDOW) or one of our activities is visible; the system
|
|
81
|
+
// silently ignores it otherwise, so it is safe to always try.
|
|
82
|
+
runCatching {
|
|
83
|
+
startActivity(
|
|
84
|
+
Intent(this, AlarmRingActivity::class.java)
|
|
85
|
+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
86
|
+
)
|
|
87
|
+
}.onFailure { Log.w(TAG, "Could not start ring activity directly", it) }
|
|
88
|
+
|
|
89
|
+
AlarmModule.emit(AlarmModule.EVENT_FIRED, alarm)
|
|
90
|
+
|
|
91
|
+
// Safety net: never ring forever. Auto-snooze (or stop) after 5 minutes.
|
|
92
|
+
handler.postDelayed({ if (alarm.snoozeEnabled) snooze(-1) else dismiss() }, AUTO_TIMEOUT_MS)
|
|
93
|
+
return START_NOT_STICKY
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private fun startInForeground(notification: Notification) {
|
|
97
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
98
|
+
ServiceCompat.startForeground(
|
|
99
|
+
this, NOTIFICATION_ID, notification,
|
|
100
|
+
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
|
101
|
+
)
|
|
102
|
+
} else {
|
|
103
|
+
startForeground(NOTIFICATION_ID, notification)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private fun snooze(minutes: Int) {
|
|
108
|
+
val alarm = current ?: run { stopSelf(); return }
|
|
109
|
+
val mins = if (minutes > 0) minutes else alarm.snoozeMinutes
|
|
110
|
+
runCatching { AlarmScheduler.scheduleSnooze(this, alarm, mins) }
|
|
111
|
+
.onFailure { Log.e(TAG, "Could not schedule snooze", it) }
|
|
112
|
+
AlarmModule.emit(AlarmModule.EVENT_SNOOZED, alarm)
|
|
113
|
+
stopRinging()
|
|
114
|
+
stopSelf()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun dismiss() {
|
|
118
|
+
val alarm = current ?: run { stopSelf(); return }
|
|
119
|
+
// A dismissed snoozed alarm must not ring again from its snooze intent.
|
|
120
|
+
AlarmScheduler.cancelSnooze(this, alarm.id)
|
|
121
|
+
AlarmModule.emit(AlarmModule.EVENT_DISMISSED, alarm)
|
|
122
|
+
stopRinging()
|
|
123
|
+
stopSelf()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private fun stopRinging() {
|
|
127
|
+
handler.removeCallbacksAndMessages(null)
|
|
128
|
+
runCatching { player?.stop() }
|
|
129
|
+
runCatching { player?.release() }
|
|
130
|
+
player = null
|
|
131
|
+
vibrator?.cancel()
|
|
132
|
+
vibrator = null
|
|
133
|
+
wakeLock?.let { if (it.isHeld) it.release() }
|
|
134
|
+
wakeLock = null
|
|
135
|
+
current = null
|
|
136
|
+
AlarmRingActivity.finishIfOpen()
|
|
137
|
+
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override fun onDestroy() {
|
|
141
|
+
stopRinging()
|
|
142
|
+
super.onDestroy()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- Sound ----
|
|
146
|
+
|
|
147
|
+
private fun startSound(alarm: AlarmData) {
|
|
148
|
+
val uri = alarm.sound.takeIf { it.isNotBlank() && it != "default" }?.let { Uri.parse(it) }
|
|
149
|
+
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
|
|
150
|
+
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
val mp = MediaPlayer()
|
|
154
|
+
player = mp
|
|
155
|
+
mp.apply {
|
|
156
|
+
setAudioAttributes(
|
|
157
|
+
AudioAttributes.Builder()
|
|
158
|
+
.setUsage(AudioAttributes.USAGE_ALARM)
|
|
159
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
160
|
+
.build()
|
|
161
|
+
)
|
|
162
|
+
setDataSource(this@AlarmService, uri)
|
|
163
|
+
isLooping = true
|
|
164
|
+
prepare()
|
|
165
|
+
start()
|
|
166
|
+
}
|
|
167
|
+
} catch (e: Exception) {
|
|
168
|
+
Log.e(TAG, "Could not play $uri, vibrating only", e)
|
|
169
|
+
runCatching { player?.release() }
|
|
170
|
+
player = null
|
|
171
|
+
startVibration() // last resort: at least wake the user somehow
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- Vibration ----
|
|
176
|
+
|
|
177
|
+
@Suppress("DEPRECATION")
|
|
178
|
+
private fun startVibration() {
|
|
179
|
+
if (vibrator != null) return
|
|
180
|
+
val v = getSystemService(Vibrator::class.java) ?: return
|
|
181
|
+
vibrator = v
|
|
182
|
+
val pattern = longArrayOf(0, 800, 400, 800, 800)
|
|
183
|
+
v.vibrate(
|
|
184
|
+
VibrationEffect.createWaveform(pattern, 0),
|
|
185
|
+
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).build()
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- Plumbing ----
|
|
190
|
+
|
|
191
|
+
private fun acquireWakeLock() {
|
|
192
|
+
val pm = getSystemService(PowerManager::class.java)
|
|
193
|
+
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "rnalarm:ring").apply {
|
|
194
|
+
acquire(AUTO_TIMEOUT_MS + 60_000)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private fun ensureChannel(): NotificationManager {
|
|
199
|
+
val manager = getSystemService(NotificationManager::class.java)
|
|
200
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
201
|
+
manager.createNotificationChannel(
|
|
202
|
+
NotificationChannel(CHANNEL_ID, "Alarms", NotificationManager.IMPORTANCE_HIGH).apply {
|
|
203
|
+
setSound(null, null) // the service plays its own sound
|
|
204
|
+
enableVibration(false)
|
|
205
|
+
setBypassDnd(true)
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
return manager
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private fun placeholderNotification(): Notification {
|
|
213
|
+
ensureChannel()
|
|
214
|
+
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
215
|
+
.setSmallIcon(applicationInfo.icon)
|
|
216
|
+
.setContentTitle("Alarm")
|
|
217
|
+
.build()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private fun buildNotification(alarm: AlarmData): Notification {
|
|
221
|
+
ensureChannel()
|
|
222
|
+
val ringIntent = Intent(this, AlarmRingActivity::class.java)
|
|
223
|
+
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
224
|
+
val fullScreen = PendingIntent.getActivity(
|
|
225
|
+
this, 1, ringIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
226
|
+
)
|
|
227
|
+
val b = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
228
|
+
.setSmallIcon(applicationInfo.icon)
|
|
229
|
+
.setContentTitle(alarm.title.ifBlank { "Alarm" })
|
|
230
|
+
.setContentText(alarm.body.ifBlank { "Alarm is ringing" })
|
|
231
|
+
.setPriority(NotificationCompat.PRIORITY_MAX)
|
|
232
|
+
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
|
233
|
+
.setOngoing(true)
|
|
234
|
+
.setAutoCancel(false)
|
|
235
|
+
.setFullScreenIntent(fullScreen, true)
|
|
236
|
+
.setContentIntent(fullScreen)
|
|
237
|
+
.addAction(0, "Stop", commandPI(ACTION_DISMISS, 2))
|
|
238
|
+
if (alarm.snoozeEnabled) {
|
|
239
|
+
b.addAction(0, "Snooze", commandPI(ACTION_SNOOZE, 3))
|
|
240
|
+
}
|
|
241
|
+
return b.build()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private fun commandPI(action: String, code: Int): PendingIntent =
|
|
245
|
+
PendingIntent.getService(
|
|
246
|
+
this, code, Intent(this, AlarmService::class.java).setAction(action),
|
|
247
|
+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
companion object {
|
|
251
|
+
private const val TAG = "AlarmService"
|
|
252
|
+
private const val CHANNEL_ID = "rn_alarm_channel"
|
|
253
|
+
private const val NOTIFICATION_ID = 1
|
|
254
|
+
private const val AUTO_TIMEOUT_MS = 5L * 60 * 1000
|
|
255
|
+
|
|
256
|
+
const val ACTION_SNOOZE = "com.alarm.service.SNOOZE"
|
|
257
|
+
const val ACTION_DISMISS = "com.alarm.service.DISMISS"
|
|
258
|
+
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
|
|
259
|
+
|
|
260
|
+
/** The alarm currently ringing, readable by [AlarmRingActivity] and [AlarmModule]. */
|
|
261
|
+
@Volatile
|
|
262
|
+
var current: AlarmData? = null
|
|
263
|
+
private set
|
|
264
|
+
|
|
265
|
+
fun start(context: Context, alarmId: String) {
|
|
266
|
+
androidx.core.content.ContextCompat.startForegroundService(
|
|
267
|
+
context,
|
|
268
|
+
Intent(context, AlarmService::class.java).putExtra(AlarmScheduler.EXTRA_ID, alarmId)
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
fun snooze(context: Context, minutes: Int = -1) =
|
|
273
|
+
command(context, Intent(context, AlarmService::class.java)
|
|
274
|
+
.setAction(ACTION_SNOOZE).putExtra(EXTRA_SNOOZE_MINUTES, minutes))
|
|
275
|
+
|
|
276
|
+
fun dismiss(context: Context) =
|
|
277
|
+
command(context, Intent(context, AlarmService::class.java).setAction(ACTION_DISMISS))
|
|
278
|
+
|
|
279
|
+
private fun command(context: Context, intent: Intent) {
|
|
280
|
+
if (current == null) return // nothing ringing; avoid starting a service for nothing
|
|
281
|
+
runCatching { context.startService(intent) }
|
|
282
|
+
.onFailure { Log.w(TAG, "Could not send ${intent.action}", it) }
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
package com.alarm
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
|
|
5
|
+
object AlarmStore {
|
|
6
|
+
private fun prefs(ctx: Context) = ctx.getSharedPreferences("rn_alarms", Context.MODE_PRIVATE)
|
|
7
|
+
|
|
8
|
+
fun save(ctx: Context, a: AlarmData) = prefs(ctx).edit().putString(a.id, a.toJson()).apply()
|
|
9
|
+
fun get(ctx: Context, id: String) = prefs(ctx).getString(id, null)?.let { AlarmData.fromJson(it) }
|
|
10
|
+
fun all(ctx: Context) = prefs(ctx).all.values.mapNotNull { (it as? String)?.let(AlarmData::fromJson) }
|
|
11
|
+
fun remove(ctx: Context, id: String) = prefs(ctx).edit().remove(id).apply()
|
|
12
|
+
fun clear(ctx: Context) = prefs(ctx).edit().clear().apply()
|
|
13
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package com.alarm
|
|
2
|
+
|
|
3
|
+
import android.content.BroadcastReceiver
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import android.content.Intent
|
|
6
|
+
import android.util.Log
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AlarmManager registrations do not survive a reboot (or an app update, or a clock
|
|
10
|
+
* change that could make a computed trigger wrong). This receiver re-arms them all.
|
|
11
|
+
*/
|
|
12
|
+
class BootReceiver : BroadcastReceiver() {
|
|
13
|
+
|
|
14
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
15
|
+
when (intent.action) {
|
|
16
|
+
Intent.ACTION_BOOT_COMPLETED,
|
|
17
|
+
Intent.ACTION_MY_PACKAGE_REPLACED,
|
|
18
|
+
Intent.ACTION_TIME_CHANGED,
|
|
19
|
+
Intent.ACTION_TIMEZONE_CHANGED -> {
|
|
20
|
+
Log.i(TAG, "Re-arming alarms after ${intent.action}")
|
|
21
|
+
AlarmScheduler.scheduleAll(context)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
companion object {
|
|
27
|
+
private const val TAG = "BootReceiver"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
2
|
+
<!-- Copyright (C) 2014 The Android Open Source Project
|
|
3
|
+
|
|
4
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
you may not use this file except in compliance with the License.
|
|
6
|
+
You may obtain a copy of the License at
|
|
7
|
+
|
|
8
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
|
|
10
|
+
Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
See the License for the specific language governing permissions and
|
|
14
|
+
limitations under the License.
|
|
15
|
+
-->
|
|
16
|
+
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
|
17
|
+
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
|
|
18
|
+
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
|
|
19
|
+
android:insetTop="@dimen/abc_edit_text_inset_top_material"
|
|
20
|
+
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
|
|
21
|
+
>
|
|
22
|
+
|
|
23
|
+
<selector>
|
|
24
|
+
<!--
|
|
25
|
+
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
|
|
26
|
+
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
|
|
27
|
+
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
|
|
28
|
+
|
|
29
|
+
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
|
30
|
+
|
|
31
|
+
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
|
|
32
|
+
-->
|
|
33
|
+
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
|
|
34
|
+
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
|
|
35
|
+
</selector>
|
|
36
|
+
|
|
37
|
+
</inset>
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<resources>
|
|
2
|
+
|
|
3
|
+
<!-- Base application theme. -->
|
|
4
|
+
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
|
5
|
+
<!-- Customize your theme here. -->
|
|
6
|
+
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
|
7
|
+
</style>
|
|
8
|
+
|
|
9
|
+
</resources>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext {
|
|
3
|
+
buildToolsVersion = "37.0.0"
|
|
4
|
+
minSdkVersion = 24
|
|
5
|
+
compileSdkVersion = 37
|
|
6
|
+
targetSdkVersion = 36
|
|
7
|
+
ndkVersion = "27.1.12297006"
|
|
8
|
+
kotlinVersion = "2.2.0"
|
|
9
|
+
}
|
|
10
|
+
repositories {
|
|
11
|
+
google()
|
|
12
|
+
mavenCentral()
|
|
13
|
+
}
|
|
14
|
+
dependencies {
|
|
15
|
+
classpath("com.android.tools.build:gradle")
|
|
16
|
+
classpath("com.facebook.react:react-native-gradle-plugin")
|
|
17
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
apply plugin: "com.facebook.react.rootproject"
|
|
Binary file
|