This commit is contained in:
2026-10-18 22:42:00 +01:00
commit 2df34ab938
95 changed files with 12358 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
// cliFile = file("../../node_modules/react-native/cli.js")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = false
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.dcntrlized.alarm"
defaultConfig {
applicationId "com.dcntrlized.alarm"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
// Native alarm engine (AlarmForegroundService holds its own WebSocket so a
// trigger still lands when the JS runtime is backgrounded). OkHttp is what
// React Native already resolves; pinning it here makes the direct use
// explicit instead of relying on a transitive `implementation` dependency.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// NotificationManagerCompat / ContextCompat / ServiceInfo used by the
// module, service and player.
implementation("androidx.core:core-ktx:1.13.1")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
+83
View File
@@ -0,0 +1,83 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Alarm delivery -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Full-screen takeover over the lock screen (Android 14+ gates this per-app). -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Exact alarms: auto-stop and any scheduled re-arm must not be deferred. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Battery exemption prompt (optional; the health checklist reports the result). -->
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Foreground service: holds the armed socket while the app is backgrounded. -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- QR scanning -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!--
Full-screen alarm takeover.
- singleTask: a second trigger reuses the same instance instead of
stacking takeovers.
- excludeFromRecents: the takeover must never appear in the recents
stack; it is a transient alarm surface, not a place you navigate to.
- showWhenLocked/turnScreenOn: appear over the lock screen and wake the
panel (also applied at runtime in AlarmActivity for older APIs).
- the dedicated theme sets windowShowWhenLocked/windowTurnScreenOn so the
window flags are right before the first frame.
-->
<activity
android:name=".AlarmActivity"
android:label="@string/app_name"
android:exported="false"
android:launchMode="singleTask"
android:excludeFromRecents="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:theme="@style/AlarmTakeoverTheme"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" />
<!--
Armed-socket holder. dataSync covers "keep a network connection open for
the user"; mediaPlayback is declared too because the same service can be
playing the alarm when a trigger foregrounds it.
-->
<service
android:name=".AlarmForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|mediaPlayback" />
</application>
</manifest>
@@ -0,0 +1,93 @@
package com.dcntrlized.alarm
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
/**
* The full-screen alarm takeover.
*
* Launched by a full-screen intent from the alarm notification, so it appears
* over the lock screen and turns the screen on. It reuses the main JS bundle
* and is pointed at the `LiveAlarm` route via the intent extras, so the takeover
* is the same React screen whether the app was foreground, background or dead.
*
* Manifest requirements that make this work (see AndroidManifest.xml):
* `launchMode=singleTask`, `excludeFromRecents=true`, `showWhenLocked` and
* `turnScreenOn` on the activity, and `USE_FULL_SCREEN_INTENT`.
*/
class AlarmActivity : ReactActivity() {
override fun getMainComponentName(): String = "AlarmClient"
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
override fun onCreate(savedInstanceState: Bundle?) {
// Order matters: the flags must be set before the window is shown.
showOverLockScreen()
super.onCreate(savedInstanceState)
}
private fun showOverLockScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Dismiss the keyguard where the OS permits it (a secure lock screen is
// still honoured — we never try to bypass a PIN).
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && km?.isKeyguardLocked == true) {
km.requestDismissKeyguard(this, null)
}
}
/** Hardware back is consumed: the takeover's only exits are in-app actions. */
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
// Intentionally swallowed — see the Live alarm takeover spec.
}
override fun onDestroy() {
AlarmPlayer.stop(this)
super.onDestroy()
}
companion object {
const val EXTRA_ALERT_ID = "alarm.alertId"
const val EXTRA_MESSAGE = "alarm.message"
const val EXTRA_AUTO_STOP = "alarm.autoStopSeconds"
fun intent(context: Context, alertId: String, message: String, autoStopSeconds: Int): Intent =
Intent(context, AlarmActivity::class.java).apply {
addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_SINGLE_TOP or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS
)
putExtra(EXTRA_ALERT_ID, alertId)
putExtra(EXTRA_MESSAGE, message)
putExtra(EXTRA_AUTO_STOP, autoStopSeconds)
}
/** The alert id this launch is for, if any — read by the JS bridge. */
fun alertIdOf(activity: Activity?): String? =
activity?.intent?.getStringExtra(EXTRA_ALERT_ID)
}
}
@@ -0,0 +1,219 @@
package com.dcntrlized.alarm
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
import java.util.concurrent.TimeUnit
/**
* Keeps the armed socket alive while the app is not in the foreground.
*
* A foreground service with a persistent (low-importance) notification is the
* only supported way to hold a network connection across backgrounding on
* modern Android. The service owns its **own** WebSocket — it does not depend on
* the React Native JS runtime being alive — so a trigger still lands when the
* app has been backgrounded or the activity was destroyed.
*
* On a trigger frame it: plays the alarm through [AlarmPlayer] (max-volume
* `STREAM_ALARM` + vibration), posts the high-priority notification, and
* launches [AlarmActivity] as a full-screen intent so the takeover appears over
* the lock screen.
*
* Manifest: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC`, and the
* `foregroundServiceType="dataSync"` attribute on this `<service>`.
*/
class AlarmForegroundService : Service() {
private var socket: WebSocket? = null
private var client: OkHttpClient? = null
private var alertId: String = ""
private var wsUrl: String = ""
private var stopping = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopEverything()
return START_NOT_STICKY
}
else -> {
alertId = intent?.getStringExtra(EXTRA_ALERT_ID) ?: alertId
wsUrl = intent?.getStringExtra(EXTRA_WS_URL) ?: wsUrl
startForegroundInternal()
connect()
}
}
// Restart with the last intent if the OS kills us while armed.
return START_REDELIVER_INTENT
}
private fun startForegroundInternal() {
AlarmNotifier.ensureChannels(this)
val open = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val stop = PendingIntent.getService(
this,
1,
Intent(this, AlarmForegroundService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = android.app.Notification.Builder(this, AlarmNotifier.CHANNEL_SERVICE)
.setContentTitle("Alarm armed")
.setContentText("Listening for triggers on $alertId")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setOngoing(true)
.setContentIntent(open)
.addAction(android.app.Notification.Action.Builder(null, "Stop", stop).build())
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
AlarmNotifier.NOTIFICATION_ID_SERVICE,
notification,
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(AlarmNotifier.NOTIFICATION_ID_SERVICE, notification)
}
}
private fun connect() {
if (wsUrl.isEmpty()) {
Log.w(TAG, "no ws url; service stays up but cannot listen")
return
}
disconnect()
val c = OkHttpClient.Builder()
.pingInterval(25, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
client = c
val request = Request.Builder().url(wsUrl).build()
socket = c.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
handleFrame(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.w(TAG, "socket failure: ${t.message}")
// OkHttp does not auto-reconnect; a plain retry is enough here
// because the JS client owns the visible reconnect state.
if (!stopping) {
android.os.Handler(mainLooper).postDelayed({ if (!stopping) connect() }, 5000)
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
if (!stopping) {
android.os.Handler(mainLooper).postDelayed({ if (!stopping) connect() }, 5000)
}
}
})
}
private fun handleFrame(text: String) {
try {
val json = JSONObject(text)
if (json.optString("type") != "alarm") return
val id = json.optString("alertId", alertId)
val eventId = json.optString("eventId", "")
val action = json.optString("action", "")
val message = json.optString("message", "")
val autoStop = json.optInt("autoStopSeconds", 0)
// Acknowledge so the server can account for delivery.
if (eventId.isNotEmpty()) {
socket?.send(JSONObject().put("type", "ack").put("eventId", eventId).toString())
}
when (action) {
"trigger" -> {
val volumeRaised = AlarmPlayer.play(this, id, autoStop)
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
// Full-screen intent: the takeover covers the lock screen.
try {
startActivity(AlarmActivity.intent(this, id, message, autoStop))
} catch (e: Exception) {
Log.w(TAG, "could not start AlarmActivity: ${e.message}")
}
}
"disarm" -> {
AlarmPlayer.stop(this)
AlarmNotifier.cancelAlarm(this)
}
}
} catch (e: Exception) {
Log.w(TAG, "bad frame: ${e.message}")
}
}
private fun disconnect() {
try {
socket?.close(1000, "client closing")
} catch (_: Exception) {
}
socket = null
client?.dispatcher?.executorService?.shutdown()
client = null
}
private fun stopEverything() {
stopping = true
disconnect()
AlarmPlayer.stop(this)
AlarmNotifier.cancelAlarm(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
override fun onDestroy() {
stopping = true
disconnect()
super.onDestroy()
}
companion object {
private const val TAG = "AlarmService"
const val ACTION_STOP = "com.dcntrlized.alarm.STOP"
const val EXTRA_ALERT_ID = "alarm.alertId"
const val EXTRA_WS_URL = "alarm.wsUrl"
fun start(context: Context, alertId: String, wsUrl: String) {
val intent = Intent(context, AlarmForegroundService::class.java)
.putExtra(EXTRA_ALERT_ID, alertId)
.putExtra(EXTRA_WS_URL, wsUrl)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.startService(
Intent(context, AlarmForegroundService::class.java).setAction(ACTION_STOP)
)
}
}
}
@@ -0,0 +1,307 @@
package com.dcntrlized.alarm
import android.Manifest
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.WritableMap
/**
* JS bridge for the native alarm engine. Mirrors `src/services/alarmNative.ts`
* exactly — keep the two in step.
*
* Every method resolves; none reject. The JS side treats a rejection as
* "unavailable", and a resolve with `ok:false` carries the reason so the UI can
* explain which rung of the escalation ladder actually fired.
*/
class AlarmModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = NAME
@ReactMethod
fun playAlarm(alertId: String, message: String, autoStopSeconds: Double, promise: Promise) {
try {
val volumeRaised = AlarmPlayer.play(reactContext, alertId, autoStopSeconds.toInt())
promise.resolve(result(ok = true, volumeRaised = volumeRaised))
} catch (e: Exception) {
promise.resolve(result(ok = false, reason = e.message ?: "play-failed"))
}
}
@ReactMethod
fun stopAlarm(promise: Promise) {
try {
AlarmPlayer.stop(reactContext)
AlarmNotifier.cancelAlarm(reactContext)
promise.resolve(result(ok = true))
} catch (e: Exception) {
promise.resolve(result(ok = false, reason = e.message ?: "stop-failed"))
}
}
@ReactMethod
fun startService(alertId: String, subscribeToken: String, wsUrl: String, promise: Promise) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(reactContext, Manifest.permission.POST_NOTIFICATIONS) !=
PackageManager.PERMISSION_GRANTED
) {
// A foreground service without its notification is killed by the
// OS; refuse loudly instead of starting something that dies.
promise.resolve(result(ok = false, reason = "notifications-not-granted"))
return
}
AlarmForegroundService.start(reactContext, alertId, wsUrl)
promise.resolve(result(ok = true))
} catch (e: Exception) {
promise.resolve(result(ok = false, reason = e.message ?: "service-failed"))
}
}
@ReactMethod
fun stopService(promise: Promise) {
try {
AlarmForegroundService.stop(reactContext)
promise.resolve(result(ok = true))
} catch (e: Exception) {
promise.resolve(result(ok = false, reason = e.message ?: "service-failed"))
}
}
@ReactMethod
fun getPermissions(promise: Promise) {
promise.resolve(permissionMap())
}
/**
* `name` is one of `notifications` | `fullScreenIntent` | `exactAlarm` |
* `battery`. Android permission prompts must run on an activity, so the
* module stores the pending promise and `AlarmPermissionActivity` (or the
* current activity's result callback) resolves it.
*/
@ReactMethod
fun requestPermission(name: String, promise: Promise) {
try {
when (name) {
"notifications" -> requestNotifications(promise)
"fullScreenIntent" -> {
openSettings(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)
promise.resolve(permissionMap())
}
"exactAlarm" -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
openSettings(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
}
promise.resolve(permissionMap())
}
"battery" -> {
requestBatteryExemption()
promise.resolve(permissionMap())
}
else -> promise.resolve(permissionMap())
}
} catch (e: Exception) {
promise.resolve(permissionMap())
}
}
/**
* One-shot read of the alert a native full-screen launch is for.
*
* [AlarmActivity] is started from the foreground service with the alert id
* in its intent, so the JS takeover can render the right alert even when
* the process was cold-started. The extras are consumed here so a later
* remount does not re-open a stale takeover.
*/
@ReactMethod
fun getLaunchAlert(promise: Promise) {
try {
val activity = reactContext.currentActivity
val alertId = AlarmActivity.alertIdOf(activity)
if (activity == null || alertId.isNullOrEmpty()) {
promise.resolve(null)
return
}
val intent = activity.intent
val map = Arguments.createMap()
map.putString("alertId", alertId)
map.putString("message", intent?.getStringExtra(AlarmActivity.EXTRA_MESSAGE) ?: "")
map.putInt(
"autoStopSeconds",
intent?.getIntExtra(AlarmActivity.EXTRA_AUTO_STOP, 0) ?: 0
)
intent?.removeExtra(AlarmActivity.EXTRA_ALERT_ID)
promise.resolve(map)
} catch (e: Exception) {
promise.resolve(null)
}
}
@ReactMethod
fun isIgnoringBatteryOptimizations(promise: Promise) {
promise.resolve(isBatteryUnrestricted())
}
@ReactMethod
fun openFullScreenIntentSettings(promise: Promise) {
openSettings(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)
promise.resolve(result(ok = true))
}
/**
* Opens the system volume UI. Used by the takeover's "Raise volume" recovery
* action when the OS refused to let us take the alarm stream — a warning the
* user cannot act on is just noise.
*/
@ReactMethod
fun openSoundSettings(promise: Promise) {
try {
val intent = Intent(Settings.ACTION_SOUND_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
reactContext.startActivity(intent)
promise.resolve(result(ok = true))
} catch (e: Exception) {
promise.resolve(result(ok = false, reason = e.message ?: "no-sound-settings"))
}
}
/** Opens this app's Android settings page (used by the camera-denied state). */
@ReactMethod
fun openAppSettings(promise: Promise) {
openSettings(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
promise.resolve(result(ok = true))
}
@ReactMethod
fun openExactAlarmSettings(promise: Promise) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
openSettings(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
}
promise.resolve(result(ok = true))
}
// ── helpers ──────────────────────────────────────────────────────────────
private fun requestNotifications(promise: Promise) {
val activity = reactContext.currentActivity
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || activity == null) {
promise.resolve(permissionMap())
return
}
ActivityCompat.requestPermissions(
activity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQ_NOTIFICATIONS
)
// The system dialog is asynchronous; report the current state and let
// the JS side re-read it when the app returns to the foreground.
promise.resolve(permissionMap())
}
private fun requestBatteryExemption() {
if (isBatteryUnrestricted()) return
try {
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
.setData(Uri.parse("package:${reactContext.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
reactContext.startActivity(intent)
} catch (e: Exception) {
openSettings(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
}
}
private fun openSettings(action: String) {
try {
val intent = Intent(action)
.setData(Uri.parse("package:${reactContext.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
reactContext.startActivity(intent)
} catch (e: Exception) {
// Some OEMs do not implement the per-app action; fall back to the
// app details screen rather than crashing.
val fallback = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
.setData(Uri.parse("package:${reactContext.packageName}"))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
reactContext.startActivity(fallback)
}
}
private fun isBatteryUnrestricted(): Boolean {
val pm = reactContext.getSystemService(Context.POWER_SERVICE) as? PowerManager ?: return false
return pm.isIgnoringBatteryOptimizations(reactContext.packageName)
}
private fun hasNotifications(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return NotificationManagerCompat.from(reactContext).areNotificationsEnabled()
}
return ContextCompat.checkSelfPermission(reactContext, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED &&
NotificationManagerCompat.from(reactContext).areNotificationsEnabled()
}
private fun hasFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val nm = reactContext.getSystemService(android.app.NotificationManager::class.java)
return nm?.canUseFullScreenIntent() ?: false
}
private fun hasExactAlarm(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val am = reactContext.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
return am.canScheduleExactAlarms()
}
/**
* `canForceVolume` is our own honest signal: it is true only when the alarm
* stream is not silenced by the ringer mode. The JS layer uses it to decide
* whether to promise max volume or to lean on the notification fallback.
*/
private fun canForceVolume(): Boolean {
val am = reactContext.getSystemService(Context.AUDIO_SERVICE) as? android.media.AudioManager
?: return false
return am.getStreamVolume(android.media.AudioManager.STREAM_ALARM) > 0 ||
am.ringerMode != android.media.AudioManager.RINGER_MODE_SILENT
}
private fun permissionMap(): WritableMap {
val map = Arguments.createMap()
map.putBoolean("notifications", hasNotifications())
map.putBoolean("fullScreenIntent", hasFullScreenIntent())
map.putBoolean("exactAlarm", hasExactAlarm())
map.putBoolean("batteryUnrestricted", isBatteryUnrestricted())
map.putBoolean("canForceVolume", canForceVolume())
return map
}
private fun result(
ok: Boolean,
volumeRaised: Boolean? = null,
reason: String? = null
): WritableMap {
val map = Arguments.createMap()
map.putBoolean("ok", ok)
volumeRaised?.let { map.putBoolean("volumeRaised", it) }
reason?.let { map.putString("reason", it) }
return map
}
companion object {
const val NAME = "AlarmModule"
private const val REQ_NOTIFICATIONS = 9101
}
}
@@ -0,0 +1,19 @@
package com.dcntrlized.alarm
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
/**
* Registers [AlarmModule]. This package is hand-written (not autolinked) because
* it lives in the app module rather than a separate library, so it is added
* explicitly in `MainApplication.kt`.
*/
class AlarmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(AlarmModule(reactContext))
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
emptyList()
}
@@ -0,0 +1,228 @@
package com.dcntrlized.alarm
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
/**
* Max-volume alarm playback.
*
* Escalation ladder, in order:
* 1. `STREAM_ALARM` at `setVolume(1f)` — the only stream that ignores the
* media volume and, on most devices, keeps ringing in silent mode;
* 2. a `Vibrator` waveform alongside the audio, so a muted ringer still
* reaches the user;
* 3. (see [AlarmNotifier]) a high-priority notification with a "Raise your
* volume" action when the OS refuses the alarm stream entirely.
*
* We never report success unless the alarm stream was actually raised, so the
* UI can tell the truth about which rung of the ladder carried the alarm.
*/
object AlarmPlayer {
private const val TAG = "AlarmPlayer"
private var player: MediaPlayer? = null
private var vibrator: Vibrator? = null
/** Vibration pattern: wait 0, buzz 700, rest 400, repeated from index 1. */
private val VIBRATION_PATTERN = longArrayOf(0L, 700L, 400L)
@Synchronized
fun play(context: Context, alertId: String, autoStopSeconds: Int): Boolean {
stop(context)
val volumeRaised = raiseAlarmStream(context)
try {
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
val mp = MediaPlayer()
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
mp.setDataSource(context, uri)
mp.isLooping = true
mp.setVolume(1f, 1f)
mp.prepare()
mp.start()
player = mp
} catch (e: Exception) {
Log.w(TAG, "alarm audio failed: ${e.message}")
}
startVibration(context)
AlarmNotifier.showAlarmNotification(context, alertId, volumeRaised, autoStopSeconds)
return volumeRaised
}
@Synchronized
fun stop(context: Context) {
try {
player?.let {
if (it.isPlaying) it.stop()
it.release()
}
} catch (e: Exception) {
Log.w(TAG, "player stop failed: ${e.message}")
} finally {
player = null
}
try {
vibrator?.cancel()
} catch (e: Exception) {
Log.w(TAG, "vibrator cancel failed: ${e.message}")
} finally {
vibrator = null
}
}
/**
* Raise the alarm stream and report whether the OS let us.
*
* `setStreamVolume` is a no-op on some OEM builds and throws
* `SecurityException` under DND. Both are reported as `false` rather than
* swallowed, because the notification fallback depends on knowing.
*/
private fun raiseAlarmStream(context: Context): Boolean {
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return false
return try {
val max = am.getStreamMaxVolume(AudioManager.STREAM_ALARM)
am.setStreamVolume(AudioManager.STREAM_ALARM, max, 0)
am.getStreamVolume(AudioManager.STREAM_ALARM) >= max
} catch (e: Exception) {
Log.w(TAG, "could not raise STREAM_ALARM: ${e.message}")
false
}
}
private fun startVibration(context: Context) {
val v: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
if (v == null || !v.hasVibrator()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
v.vibrate(VibrationEffect.createWaveform(VIBRATION_PATTERN, 1))
} else {
@Suppress("DEPRECATION")
v.vibrate(VIBRATION_PATTERN, 1)
}
vibrator = v
} catch (e: Exception) {
Log.w(TAG, "vibration failed: ${e.message}")
}
}
}
/** Notification channels + the alarm notification, including the volume fallback. */
object AlarmNotifier {
const val CHANNEL_ALARM = "alarm_trigger"
const val CHANNEL_SERVICE = "alarm_armed"
fun ensureChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val nm = context.getSystemService(NotificationManager::class.java) ?: return
val alarm = NotificationChannel(
CHANNEL_ALARM,
"Alarm triggers",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Fires at full volume when an alert is triggered"
enableVibration(true)
setShowBadge(true)
// The alarm is the payload: bypass DND where the user allows it.
setBypassDnd(true)
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
}
nm.createNotificationChannel(alarm)
val service = NotificationChannel(
CHANNEL_SERVICE,
"Armed alert",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps the connection to the alarm service open while armed"
setShowBadge(false)
}
nm.createNotificationChannel(service)
}
/**
* High-priority alarm notification. When `volumeRaised` is false the
* notification carries a "Raise your volume" action, because the OS refused
* the forced alarm stream and the user has to do it.
*/
fun showAlarmNotification(
context: Context,
alertId: String,
volumeRaised: Boolean,
autoStopSeconds: Int
) {
ensureChannels(context)
val nm = context.getSystemService(NotificationManager::class.java) ?: return
val open = android.app.PendingIntent.getActivity(
context,
alertId.hashCode(),
AlarmActivity.intent(context, alertId, "", autoStopSeconds),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
val builder = android.app.Notification.Builder(context, CHANNEL_ALARM)
.setContentTitle("Alarm")
.setContentText(if (volumeRaised) "Alarm ringing" else "Raise your volume to hear the alarm")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(android.app.Notification.CATEGORY_ALARM)
.setPriority(android.app.Notification.PRIORITY_MAX)
.setAutoCancel(false)
.setOngoing(true)
.setVisibility(android.app.Notification.VISIBILITY_PUBLIC)
.setContentIntent(open)
.setFullScreenIntent(open, true)
if (!volumeRaised) {
val raise = android.app.PendingIntent.getActivity(
context,
alertId.hashCode() + 1,
android.content.Intent(android.provider.Settings.ACTION_SOUND_SETTINGS),
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
)
builder.addAction(
android.app.Notification.Action.Builder(
null,
"Raise your volume",
raise
).build()
)
}
try {
nm.notify(NOTIFICATION_ID_ALARM, builder.build())
} catch (e: SecurityException) {
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
}
}
fun cancelAlarm(context: Context) {
val nm = context.getSystemService(NotificationManager::class.java) ?: return
nm.cancel(NOTIFICATION_ID_ALARM)
}
const val NOTIFICATION_ID_ALARM = 4711
const val NOTIFICATION_ID_SERVICE = 4712
}
@@ -0,0 +1,22 @@
package com.dcntrlized.alarm
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "AlarmClient"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
}
@@ -0,0 +1,28 @@
package com.dcntrlized.alarm
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// The alarm engine lives in the app module (not a separate library),
// so it is registered by hand rather than autolinked.
add(AlarmPackage())
},
)
}
override fun onCreate() {
super.onCreate()
loadReactNative(this)
}
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,4 @@
<resources>
<!-- DESIGN.md takeover flat fallback (`takeoverFlat #A82A15`). -->
<color name="alarm_takeover_flat">#A82A15</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Alarm</string>
</resources>
@@ -0,0 +1,22 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
<!--
Full-screen alarm takeover. Flat takeover colour so the first frame is the
right colour before React mounts. The lock-screen/turn-screen behaviour
comes from the activity's `showWhenLocked`/`turnScreenOn` manifest
attributes plus the runtime window flags in AlarmActivity — there is no
theme attribute for them (`android:windowShowWhenLocked` does not exist).
-->
<style name="AlarmTakeoverTheme" parent="AppTheme">
<item name="android:windowBackground">@color/alarm_takeover_flat</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
</style>
</resources>