[feat](client): deliver a trigger as a real alarm-clock takeover
A notification is not a delivery path: Android 14 restricts full-screen intents to apps it classifies as alarm apps, and setAlarmClock is both the strongest alarm API and the evidence for that classification. Schedule the takeover through AlarmManager instead of startActivity, keep a standing alarm-clock entry while armed, restore it after reboot (the wake also re-arms the socket), and persist the honest listening state so a platform stop is visible. Run the socket as specialUse on API 34+ (dataSync is capped at 6h/24h on 35+) and keep onTimeout as a guard. Restore the Lead release build placeholder so a plain-HTTP instance is reachable, and allow the app to arm only when exact alarms and full-screen intent are actually granted.
This commit is contained in:
@@ -0,0 +1,161 @@
|
|||||||
|
/* The app's tsconfig carries only jest types, so read sources through jest's
|
||||||
|
own real-module loader rather than widening the compiler config for a test. */
|
||||||
|
declare const __dirname: string;
|
||||||
|
const nodeFs = jest.requireActual('fs') as {
|
||||||
|
readFileSync(path: string, encoding: string): string;
|
||||||
|
};
|
||||||
|
const nodePath = jest.requireActual('path') as { join(...parts: string[]): string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source-level contract checks for the native alarm delivery path.
|
||||||
|
*
|
||||||
|
* Kotlin behaviour cannot be executed in this Jest run (no Android runtime, no
|
||||||
|
* device), so these assertions pin the *structure* the design depends on:
|
||||||
|
* delivery goes through `AlarmManager.setAlarmClock`, there is no
|
||||||
|
* notification-based delivery path, the standing entry is cancelled when the
|
||||||
|
* device stops following an alert, and the boot receiver re-arms it.
|
||||||
|
*
|
||||||
|
* They are deliberately narrow. They prove the calls exist; they cannot prove
|
||||||
|
* that Android accepts them, nor that scheduling lifts the full-screen-intent
|
||||||
|
* restriction. Those are listed as unverified in the report.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NATIVE = nodePath.join(__dirname, '..', 'android', 'app', 'src', 'main', 'java', 'com', 'dcntrlized', 'alarm');
|
||||||
|
const MANIFEST = nodePath.join(__dirname, '..', 'android', 'app', 'src', 'main', 'AndroidManifest.xml');
|
||||||
|
|
||||||
|
function native(file: string): string {
|
||||||
|
return nodeFs.readFileSync(nodePath.join(NATIVE, file), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('native alarm delivery contract', () => {
|
||||||
|
const scheduler = native('AlarmScheduler.kt');
|
||||||
|
const service = native('AlarmForegroundService.kt');
|
||||||
|
const notifier = native('AlarmPlayer.kt');
|
||||||
|
const receiver = native('AlarmWakeReceiver.kt');
|
||||||
|
const bridge = native('AlarmModule.kt');
|
||||||
|
const player = native('AlarmPlayer.kt');
|
||||||
|
const manifest = nodeFs.readFileSync(MANIFEST, 'utf8');
|
||||||
|
|
||||||
|
it('schedules a real alarm clock with AlarmManager.setAlarmClock', () => {
|
||||||
|
expect(scheduler).toContain('setAlarmClock(');
|
||||||
|
expect(scheduler).toContain('AlarmClockInfo(');
|
||||||
|
expect(scheduler).toContain('AlarmActivity.intent(');
|
||||||
|
expect(scheduler).toContain('canScheduleExactAlarms');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires the alarm from the trigger frame through the scheduler, not startActivity', () => {
|
||||||
|
expect(service).toContain('AlarmScheduler.fireNow(');
|
||||||
|
expect(service).not.toMatch(/startActivity\(AlarmActivity\.intent/);
|
||||||
|
// A failed schedule is recorded, never replaced by a notification.
|
||||||
|
expect(service).toContain('markDeliveryFailed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a standing alarm-clock entry while armed and re-arms after reboot', () => {
|
||||||
|
expect(service).toContain('AlarmScheduler.holdStanding(');
|
||||||
|
expect(receiver).toContain('ACTION_BOOT_COMPLETED');
|
||||||
|
expect(receiver).toContain('rearmAfterBoot');
|
||||||
|
expect(receiver).toContain('ACTION_STANDING');
|
||||||
|
expect(receiver).toContain('AlarmScheduler.holdStanding(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels the entries and forgets the arm record on stop', () => {
|
||||||
|
expect(service).toContain('AlarmScheduler.cancel(');
|
||||||
|
expect(service).toContain('AlarmArmStore.clear(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the standing entry across a disarm — only the firing entry dies', () => {
|
||||||
|
const disarmAt = service.indexOf('"disarm"');
|
||||||
|
expect(disarmAt).toBeGreaterThan(-1);
|
||||||
|
const disarm = service.slice(disarmAt, disarmAt + 500);
|
||||||
|
expect(disarm).toContain('AlarmScheduler.cancelFire(');
|
||||||
|
// A disarm ends the ring, not the subscription: cancelling the standing
|
||||||
|
// entry here would drop the "armed" indicator and the re-arm point.
|
||||||
|
expect(disarm).not.toContain('AlarmScheduler.cancel(');
|
||||||
|
// cancelFire must not touch the standing entry either.
|
||||||
|
const cancelFireAt = scheduler.indexOf('fun cancelFire(');
|
||||||
|
const cancelFireBody = scheduler.slice(cancelFireAt, cancelFireAt + 800);
|
||||||
|
expect(cancelFireBody).not.toContain('REQ_STANDING');
|
||||||
|
expect(cancelFireBody).toContain('REQ_FIRE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has exactly two notification channels and no full-screen-intent notification', () => {
|
||||||
|
const channels = notifier.split('= NotificationChannel(').length - 1;
|
||||||
|
expect(channels).toBe(2);
|
||||||
|
expect(notifier).toContain('CHANNEL_VOLUME');
|
||||||
|
expect(notifier).toContain('CHANNEL_SERVICE');
|
||||||
|
// The volume warning is informational; only an alarm may take over the screen,
|
||||||
|
// and that is scheduled, not notified.
|
||||||
|
expect(notifier).not.toContain('setFullScreenIntent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only warns about volume, never about a ringing alarm', () => {
|
||||||
|
expect(notifier).toContain('Alarm volume is at ');
|
||||||
|
expect(notifier).not.toMatch(/Alarm ringing/);
|
||||||
|
expect(notifier).not.toMatch(/Alarm armed"\)/);
|
||||||
|
expect(notifier).toContain('CATEGORY_RECOMMENDATION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to arm a device that cannot deliver a real alarm', () => {
|
||||||
|
expect(bridge).toContain('exact-alarms-not-granted');
|
||||||
|
expect(bridge).toContain('full-screen-intent-not-granted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the specialUse foreground-service type with a real subtype', () => {
|
||||||
|
// dataSync's 6h/24h cumulative cap is why the primary type changed; the
|
||||||
|
// subtype text is the reason shown for a specialUse service.
|
||||||
|
expect(manifest).toContain('android:foregroundServiceType="dataSync|specialUse"');
|
||||||
|
expect(manifest).toContain('PROPERTY_SPECIAL_USE_FGS_SUBTYPE');
|
||||||
|
expect(manifest).toContain('android.permission.FOREGROUND_SERVICE_SPECIAL_USE');
|
||||||
|
expect(manifest).not.toContain('FOREGROUND_SERVICE_MEDIA_PLAYBACK');
|
||||||
|
expect(service).toContain('FOREGROUND_SERVICE_TYPE_SPECIAL_USE');
|
||||||
|
// Kept as defence in depth even though specialUse has no time limit today.
|
||||||
|
expect(service).toContain('override fun onTimeout(');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules close to now instead of rounding up to a future bucket', () => {
|
||||||
|
// The answer to "31st second or next minute" is the 31st second: the
|
||||||
|
// trigger time is now + a small margin, never a rounded tick.
|
||||||
|
const match = /FIRE_DELAY_MS\s*=\s*(\d+)L/.exec(scheduler);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const fireDelay = Number(match ? match[1] : NaN);
|
||||||
|
expect(fireDelay).toBeGreaterThan(0);
|
||||||
|
expect(fireDelay).toBeLessThanOrEqual(1000);
|
||||||
|
expect(scheduler).toContain('System.currentTimeMillis() + FIRE_DELAY_MS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains no repeating, inexact or delayed scheduling', () => {
|
||||||
|
// No bucket quantization anywhere in the firing path. The socket reconnect
|
||||||
|
// backoff in the service is the only Handler use and is not scheduling an
|
||||||
|
// alarm (asserted below by scoping this to the scheduler/player/activity).
|
||||||
|
const activity = native('AlarmActivity.kt');
|
||||||
|
for (const src of [scheduler, player, activity]) {
|
||||||
|
expect(src).not.toContain('setRepeating');
|
||||||
|
expect(src).not.toContain('setInexactRepeating');
|
||||||
|
expect(src).not.toContain('setWindow');
|
||||||
|
expect(src).not.toContain('postDelayed');
|
||||||
|
expect(src).not.toMatch(/Handler\(/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rings until the device user dismisses it — no auto-stop anywhere', () => {
|
||||||
|
// User ruling: a lost owner must not mean an alarm that expires on a timer.
|
||||||
|
for (const file of [
|
||||||
|
'AlarmScheduler.kt',
|
||||||
|
'AlarmPlayer.kt',
|
||||||
|
'AlarmActivity.kt',
|
||||||
|
'AlarmForegroundService.kt',
|
||||||
|
'AlarmModule.kt',
|
||||||
|
]) {
|
||||||
|
expect(native(file)).not.toMatch(/autoStop/i);
|
||||||
|
}
|
||||||
|
expect(player).not.toMatch(/stopTimer|expire|countdown/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('declares the exact-alarm and boot permissions it relies on', () => {
|
||||||
|
expect(manifest).toContain('android.permission.USE_EXACT_ALARM');
|
||||||
|
expect(manifest).toContain('android.permission.SCHEDULE_EXACT_ALARM');
|
||||||
|
expect(manifest).toContain('android.permission.USE_FULL_SCREEN_INTENT');
|
||||||
|
expect(manifest).toContain('android.permission.RECEIVE_BOOT_COMPLETED');
|
||||||
|
expect(manifest).toContain('.AlarmWakeReceiver');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,6 +101,12 @@ android {
|
|||||||
// Caution! In production, you need to generate your own keystore file.
|
// Caution! In production, you need to generate your own keystore file.
|
||||||
// see https://reactnative.dev/docs/signed-apk-android.
|
// see https://reactnative.dev/docs/signed-apk-android.
|
||||||
signingConfig signingConfigs.debug
|
signingConfig signingConfigs.debug
|
||||||
|
// A self-hosted instance may legitimately be plain HTTP on a LAN
|
||||||
|
// (`http://192.168.x.x:8080`), and the dev default is
|
||||||
|
// `http://127.0.0.1:8080` via `adb reverse`. The RN template leaves
|
||||||
|
// this false outside debug, which would make a release build unable
|
||||||
|
// to reach any non-TLS instance at all.
|
||||||
|
manifestPlaceholders = [usesCleartextTraffic: "true"]
|
||||||
minifyEnabled enableProguardInReleaseBuilds
|
minifyEnabled enableProguardInReleaseBuilds
|
||||||
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,24 +6,51 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
<!-- Full-screen takeover over the lock screen (Android 14+ gates this per-app). -->
|
<!--
|
||||||
|
Full-screen takeover over the lock screen. Restricted since Android 14
|
||||||
|
(API 34): auto-granted to apps the system classifies as call/alarm apps and
|
||||||
|
revoked by default otherwise. The app becomes classifiable by actually
|
||||||
|
behaving like an alarm app — AlarmManager.setAlarmClock, see
|
||||||
|
AlarmScheduler. Deliberately not removed: it is still the permission that
|
||||||
|
governs the takeover.
|
||||||
|
-->
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||||
<!-- Exact alarms: auto-stop and any scheduled re-arm must not be deferred. -->
|
<!--
|
||||||
|
Exact alarms. USE_EXACT_ALARM (API 33+) is granted without user action for
|
||||||
|
alarm-clock apps, which is exactly why off-Play distribution matters here;
|
||||||
|
SCHEDULE_EXACT_ALARM covers API 31/32 where the user still grants it.
|
||||||
|
-->
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||||
<!-- Battery exemption prompt (optional; the health checklist reports the result). -->
|
<!-- Battery exemption prompt (optional; the health checklist reports the result). -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<!-- Foreground service: holds the armed socket while the app is backgrounded. -->
|
<!-- Restore the standing alarm-clock entry after a reboot. -->
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Foreground service: holds the armed socket while the app is backgrounded.
|
||||||
|
specialUse (API 34+) is the accurate type and carries no time limit;
|
||||||
|
dataSync is used on API 29-33 only. dataSync's API 35+ 6h/24h cumulative
|
||||||
|
cap cannot be escaped by re-arming, which is why the primary type is
|
||||||
|
specialUse — the app is not distributed through Play, so it is available.
|
||||||
|
-->
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
|
||||||
|
|
||||||
<!-- QR scanning -->
|
<!-- QR scanning -->
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
||||||
|
|
||||||
|
<!-- A self-hosted instance may legitimately be plain HTTP on a LAN
|
||||||
|
(`http://192.168.x.x:8080`), and the dev default is
|
||||||
|
`http://127.0.0.1:8080` through `adb reverse`. The RN template's
|
||||||
|
`${usesCleartextTraffic}` placeholder resolves to false outside debug,
|
||||||
|
which would leave a release build unable to reach any non-TLS
|
||||||
|
instance at all. Set explicitly so a release APK is not silently
|
||||||
|
HTTP-incapable. -->
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
@@ -31,7 +58,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
android:usesCleartextTraffic="true"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
@@ -70,14 +97,33 @@
|
|||||||
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" />
|
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Armed-socket holder. dataSync covers "keep a network connection open for
|
Armed-socket holder. The subtype text is what the platform shows to a
|
||||||
the user"; mediaPlayback is declared too because the same service can be
|
reviewer/user for a specialUse service, so it states the real reason.
|
||||||
playing the alarm when a trigger foregrounds it.
|
dataSync is declared only for API 29-33, where specialUse does not exist
|
||||||
|
and dataSync has no time cap.
|
||||||
-->
|
-->
|
||||||
<service
|
<service
|
||||||
android:name=".AlarmForegroundService"
|
android:name=".AlarmForegroundService"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:foregroundServiceType="dataSync|mediaPlayback" />
|
android:foregroundServiceType="dataSync|specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android:PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="holds a persistent connection so a user-subscribed alert can be delivered within milliseconds" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Standing alarm-clock entry + boot restore. exported=false still receives
|
||||||
|
system broadcasts (same app / system only), so this cannot be poked from
|
||||||
|
outside.
|
||||||
|
-->
|
||||||
|
<receiver
|
||||||
|
android:name=".AlarmWakeReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -15,14 +15,17 @@ import com.facebook.react.defaults.DefaultReactActivityDelegate
|
|||||||
/**
|
/**
|
||||||
* The full-screen alarm takeover.
|
* The full-screen alarm takeover.
|
||||||
*
|
*
|
||||||
* Launched by a full-screen intent from the alarm notification, so it appears
|
* Launched by the `PendingIntent` of an `AlarmManager.setAlarmClock` entry (see
|
||||||
* over the lock screen and turns the screen on. It reuses the main JS bundle
|
* [AlarmScheduler]), so it appears over the lock screen and turns the screen on
|
||||||
* and is pointed at the `LiveAlarm` route via the intent extras, so the takeover
|
* — an alarm launch, not a notification tap. It reuses the main JS bundle and is
|
||||||
* is the same React screen whether the app was foreground, background or dead.
|
* 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):
|
* Manifest requirements that make this work (see AndroidManifest.xml):
|
||||||
* `launchMode=singleTask`, `excludeFromRecents=true`, `showWhenLocked` and
|
* `launchMode=singleTask`, `excludeFromRecents=true`, `showWhenLocked` and
|
||||||
* `turnScreenOn` on the activity, and `USE_FULL_SCREEN_INTENT`.
|
* `turnScreenOn` on the activity. `USE_FULL_SCREEN_INTENT` is still declared
|
||||||
|
* because it is the permission the platform uses to decide whether this app may
|
||||||
|
* take over the screen at all.
|
||||||
*/
|
*/
|
||||||
class AlarmActivity : ReactActivity() {
|
class AlarmActivity : ReactActivity() {
|
||||||
|
|
||||||
@@ -71,9 +74,8 @@ class AlarmActivity : ReactActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
const val EXTRA_ALERT_ID = "alarm.alertId"
|
const val EXTRA_ALERT_ID = "alarm.alertId"
|
||||||
const val EXTRA_MESSAGE = "alarm.message"
|
const val EXTRA_MESSAGE = "alarm.message"
|
||||||
const val EXTRA_AUTO_STOP = "alarm.autoStopSeconds"
|
|
||||||
|
|
||||||
fun intent(context: Context, alertId: String, message: String, autoStopSeconds: Int): Intent =
|
fun intent(context: Context, alertId: String, message: String): Intent =
|
||||||
Intent(context, AlarmActivity::class.java).apply {
|
Intent(context, AlarmActivity::class.java).apply {
|
||||||
addFlags(
|
addFlags(
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||||
@@ -83,7 +85,6 @@ class AlarmActivity : ReactActivity() {
|
|||||||
)
|
)
|
||||||
putExtra(EXTRA_ALERT_ID, alertId)
|
putExtra(EXTRA_ALERT_ID, alertId)
|
||||||
putExtra(EXTRA_MESSAGE, message)
|
putExtra(EXTRA_MESSAGE, message)
|
||||||
putExtra(EXTRA_AUTO_STOP, autoStopSeconds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The alert id this launch is for, if any — read by the JS bridge. */
|
/** The alert id this launch is for, if any — read by the JS bridge. */
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The alert this device is currently armed for.
|
||||||
|
*
|
||||||
|
* Persisted so the standing alarm-clock entry and the boot receiver can restore
|
||||||
|
* themselves after the process is killed or the phone reboots: without the
|
||||||
|
* `wsUrl` there is nothing to reconnect to, and an alarm app that forgets its
|
||||||
|
* alarms across a reboot is not a real alarm.
|
||||||
|
*
|
||||||
|
* Not secret material — the subscribe token is inside `wsUrl`, so the file is
|
||||||
|
* private to the app (the app already holds that token in JS storage).
|
||||||
|
*/
|
||||||
|
object AlarmArmStore {
|
||||||
|
|
||||||
|
private const val PREFS = "alarm.arm.v1"
|
||||||
|
private const val KEY_ALERT = "alertId"
|
||||||
|
private const val KEY_WS = "wsUrl"
|
||||||
|
private const val KEY_SINCE = "since"
|
||||||
|
|
||||||
|
data class Armed(val alertId: String, val wsUrl: String, val sinceMillis: Long)
|
||||||
|
|
||||||
|
fun save(context: Context, alertId: String, wsUrl: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY_ALERT, alertId)
|
||||||
|
.putString(KEY_WS, wsUrl)
|
||||||
|
.putLong(KEY_SINCE, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun current(context: Context): Armed? {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
val alertId = prefs.getString(KEY_ALERT, null)
|
||||||
|
val wsUrl = prefs.getString(KEY_WS, null)
|
||||||
|
if (alertId.isNullOrEmpty() || wsUrl.isNullOrEmpty()) return null
|
||||||
|
return Armed(alertId, wsUrl, prefs.getLong(KEY_SINCE, 0L))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(context: Context) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import android.content.Intent
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
@@ -24,13 +25,40 @@ import java.util.concurrent.TimeUnit
|
|||||||
* the React Native JS runtime being alive — so a trigger still lands when the
|
* the React Native JS runtime being alive — so a trigger still lands when the
|
||||||
* app has been backgrounded or the activity was destroyed.
|
* app has been backgrounded or the activity was destroyed.
|
||||||
*
|
*
|
||||||
* On a trigger frame it: plays the alarm through [AlarmPlayer] (max-volume
|
* On a trigger frame it plays the alarm through [AlarmPlayer] (max-volume
|
||||||
* `STREAM_ALARM` + vibration), posts the high-priority notification, and
|
* `STREAM_ALARM` + vibration) and schedules the takeover through
|
||||||
* launches [AlarmActivity] as a full-screen intent so the takeover appears over
|
* [AlarmScheduler] — a real `AlarmManager.setAlarmClock` entry, never a
|
||||||
* the lock screen.
|
* notification. A notification is only ever posted when the alarm stream could
|
||||||
|
* not be raised, to tell the user their volume is not at maximum.
|
||||||
*
|
*
|
||||||
* Manifest: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_DATA_SYNC`, and the
|
* Manifest: `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_SPECIAL_USE` +
|
||||||
* `foregroundServiceType="dataSync"` attribute on this `<service>`.
|
* `FOREGROUND_SERVICE_DATA_SYNC`, and
|
||||||
|
* `foregroundServiceType="dataSync|specialUse"` on this `<service>` — see the
|
||||||
|
* type note below for which one is used on which API level.
|
||||||
|
*
|
||||||
|
* ## Service type: `specialUse` (product decision)
|
||||||
|
*
|
||||||
|
* The service holds a connection for a user-subscribed alert, which is not
|
||||||
|
* "data sync" in the platform's sense. The types were weighed explicitly:
|
||||||
|
* - `dataSync` is capped at **6 hours cumulative per 24-hour period** on API
|
||||||
|
* 35+. The budget is cumulative, so re-arming cannot escape it — it only
|
||||||
|
* buys one more cycle — and the cap turns an always-on alarm into a part-time
|
||||||
|
* one, which contradicts "always run a real alarm".
|
||||||
|
* - `mediaPlayback` is only honest while audio is actually playing; we are
|
||||||
|
* silent most of the time.
|
||||||
|
* - `specialUse` is the type that describes what we do, and it is available to
|
||||||
|
* us precisely because this app is not distributed through Play. It has no
|
||||||
|
* time limit, so the subscription survives as long as the alert exists.
|
||||||
|
*
|
||||||
|
* API levels: `specialUse` was introduced in Android 14 (API 34), so it is
|
||||||
|
* passed on 34+; on API 29–33 we pass `dataSync`, where no cap exists (the cap
|
||||||
|
* is API 35+). The manifest declares both types for that reason.
|
||||||
|
*
|
||||||
|
* [onTimeout] is kept as **defence in depth**: if a future API level extends
|
||||||
|
* foreground-service timeouts to `specialUse` (or an OEM enforces one), the
|
||||||
|
* target-SDK-36 contract still requires stopping ourselves or the system
|
||||||
|
* throws. The standing alarm-clock wake also re-arms the socket, which is the
|
||||||
|
* belt-and-braces path if a stop happens anyway.
|
||||||
*/
|
*/
|
||||||
class AlarmForegroundService : Service() {
|
class AlarmForegroundService : Service() {
|
||||||
|
|
||||||
@@ -59,8 +87,47 @@ class AlarmForegroundService : Service() {
|
|||||||
return START_REDELIVER_INTENT
|
return START_REDELIVER_INTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android 15+ (API 35+) foreground-service time limit handler.
|
||||||
|
*
|
||||||
|
* Two jobs, in this order:
|
||||||
|
* 1. **Stop ourselves.** If the app targets API 35+ and this method returns
|
||||||
|
* without stopping the service, the system throws and crashes the app.
|
||||||
|
* 2. **Tell the truth.** Cancel the "Alarm armed" notification (it is no
|
||||||
|
* longer true), post a visible "stopped listening" notification, and
|
||||||
|
* persist the state so the UI can say the same thing on next open.
|
||||||
|
*
|
||||||
|
* It receives the `startId` of the timed-out start request and the
|
||||||
|
* foreground-service type that timed out. `specialUse` has no time limit
|
||||||
|
* today, so in practice this is a guard for dataSync (API 29-33) and for any
|
||||||
|
* future platform limit — it must keep existing because the target-SDK-36
|
||||||
|
* contract requires stopping ourselves on timeout.
|
||||||
|
*/
|
||||||
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
||||||
|
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||||
|
Log.w(TAG, "foreground service timed out (fgsType=$fgsType, startId=$startId)")
|
||||||
|
stopping = true
|
||||||
|
disconnect()
|
||||||
|
AlarmPlayer.stop(this)
|
||||||
|
AlarmListeningState.markTimedOut(this)
|
||||||
|
AlarmNotifier.cancelArmed(this)
|
||||||
|
isRunning = false
|
||||||
|
// No notification here on purpose: the user ruling is that the only
|
||||||
|
// notification about an alarm is the volume warning. The timeout is
|
||||||
|
// surfaced in-app (alert screen + Settings) and in the persisted state.
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
stopSelf(startId)
|
||||||
|
}
|
||||||
|
|
||||||
private fun startForegroundInternal() {
|
private fun startForegroundInternal() {
|
||||||
AlarmNotifier.ensureChannels(this)
|
AlarmNotifier.ensureChannels(this)
|
||||||
|
// Record the honest state *after* startForeground succeeds below: if the
|
||||||
|
// OS rejects the start, we throw out of here and must not claim to run.
|
||||||
val open = PendingIntent.getActivity(
|
val open = PendingIntent.getActivity(
|
||||||
this,
|
this,
|
||||||
0,
|
0,
|
||||||
@@ -75,7 +142,7 @@ class AlarmForegroundService : Service() {
|
|||||||
)
|
)
|
||||||
val notification = android.app.Notification.Builder(this, AlarmNotifier.CHANNEL_SERVICE)
|
val notification = android.app.Notification.Builder(this, AlarmNotifier.CHANNEL_SERVICE)
|
||||||
.setContentTitle("Alarm armed")
|
.setContentTitle("Alarm armed")
|
||||||
.setContentText("Listening for triggers on $alertId")
|
.setContentText("Listening for triggers")
|
||||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setContentIntent(open)
|
.setContentIntent(open)
|
||||||
@@ -83,14 +150,24 @@ class AlarmForegroundService : Service() {
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
startForeground(
|
// specialUse exists from API 34; before that use dataSync (no cap on
|
||||||
AlarmNotifier.NOTIFICATION_ID_SERVICE,
|
// those levels) so the type is always a declared one.
|
||||||
notification,
|
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
} else {
|
||||||
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||||
)
|
}
|
||||||
|
startForeground(AlarmNotifier.NOTIFICATION_ID_SERVICE, notification, type)
|
||||||
} else {
|
} else {
|
||||||
startForeground(AlarmNotifier.NOTIFICATION_ID_SERVICE, notification)
|
startForeground(AlarmNotifier.NOTIFICATION_ID_SERVICE, notification)
|
||||||
}
|
}
|
||||||
|
AlarmListeningState.markRunning(this, alertId)
|
||||||
|
isRunning = true
|
||||||
|
AlarmArmStore.save(this, alertId, wsUrl)
|
||||||
|
// Standing alarm-clock entry: the status-bar "armed" indicator, the
|
||||||
|
// re-arm point if the service is ever stopped, and the alarm-app
|
||||||
|
// classification evidence.
|
||||||
|
AlarmScheduler.holdStanding(this, alertId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun connect() {
|
private fun connect() {
|
||||||
@@ -135,7 +212,6 @@ class AlarmForegroundService : Service() {
|
|||||||
val eventId = json.optString("eventId", "")
|
val eventId = json.optString("eventId", "")
|
||||||
val action = json.optString("action", "")
|
val action = json.optString("action", "")
|
||||||
val message = json.optString("message", "")
|
val message = json.optString("message", "")
|
||||||
val autoStop = json.optInt("autoStopSeconds", 0)
|
|
||||||
|
|
||||||
// Acknowledge so the server can account for delivery.
|
// Acknowledge so the server can account for delivery.
|
||||||
if (eventId.isNotEmpty()) {
|
if (eventId.isNotEmpty()) {
|
||||||
@@ -144,18 +220,24 @@ class AlarmForegroundService : Service() {
|
|||||||
|
|
||||||
when (action) {
|
when (action) {
|
||||||
"trigger" -> {
|
"trigger" -> {
|
||||||
val volumeRaised = AlarmPlayer.play(this, id, autoStop)
|
val volumeRaised = AlarmPlayer.play(this, id)
|
||||||
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
Log.i(TAG, "trigger $id (alarm stream raised: $volumeRaised)")
|
||||||
// Full-screen intent: the takeover covers the lock screen.
|
// A real alarm, not a notification: setAlarmClock is what an
|
||||||
try {
|
// alarm app does and what earns the full-screen classification.
|
||||||
startActivity(AlarmActivity.intent(this, id, message, autoStop))
|
if (!AlarmScheduler.fireNow(this, id, message)) {
|
||||||
} catch (e: Exception) {
|
// Never degrade silently. The record is read by the UI as
|
||||||
Log.w(TAG, "could not start AlarmActivity: ${e.message}")
|
// "this device could not deliver the alarm".
|
||||||
|
AlarmListeningState.markDeliveryFailed(this)
|
||||||
|
Log.w(TAG, "could not schedule the alarm clock for $id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"disarm" -> {
|
"disarm" -> {
|
||||||
AlarmPlayer.stop(this)
|
AlarmPlayer.stop(this)
|
||||||
AlarmNotifier.cancelAlarm(this)
|
AlarmNotifier.cancelVolumeWarning(this)
|
||||||
|
// Only the firing entry dies here: a disarm ends this ring,
|
||||||
|
// not the subscription. The standing entry (alarm-app status
|
||||||
|
// + dataSync re-arm point) must survive until stop/unsubscribe.
|
||||||
|
AlarmScheduler.cancelFire(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -177,7 +259,14 @@ class AlarmForegroundService : Service() {
|
|||||||
stopping = true
|
stopping = true
|
||||||
disconnect()
|
disconnect()
|
||||||
AlarmPlayer.stop(this)
|
AlarmPlayer.stop(this)
|
||||||
AlarmNotifier.cancelAlarm(this)
|
AlarmNotifier.cancelVolumeWarning(this)
|
||||||
|
// Explicit stop (user asked): idle, not "timed out". Leaving the arm
|
||||||
|
// record or the standing entry behind would keep an alarm icon for an
|
||||||
|
// alert this device no longer follows.
|
||||||
|
AlarmScheduler.cancel(this)
|
||||||
|
AlarmArmStore.clear(this)
|
||||||
|
AlarmListeningState.markIdle(this)
|
||||||
|
isRunning = false
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
} else {
|
} else {
|
||||||
@@ -189,6 +278,7 @@ class AlarmForegroundService : Service() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
stopping = true
|
stopping = true
|
||||||
|
isRunning = false
|
||||||
disconnect()
|
disconnect()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
@@ -196,6 +286,10 @@ class AlarmForegroundService : Service() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AlarmService"
|
private const val TAG = "AlarmService"
|
||||||
const val ACTION_STOP = "com.dcntrlized.alarm.STOP"
|
const val ACTION_STOP = "com.dcntrlized.alarm.STOP"
|
||||||
|
/** Visible to in-process watchdog/receiver code; not persisted. */
|
||||||
|
@Volatile
|
||||||
|
var isRunning: Boolean = false
|
||||||
|
private set
|
||||||
const val EXTRA_ALERT_ID = "alarm.alertId"
|
const val EXTRA_ALERT_ID = "alarm.alertId"
|
||||||
const val EXTRA_WS_URL = "alarm.wsUrl"
|
const val EXTRA_WS_URL = "alarm.wsUrl"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Durable record of whether this device is actually listening for triggers.
|
||||||
|
*
|
||||||
|
* Why this exists: a foreground service can be stopped by the OS without the
|
||||||
|
* user asking — the `dataSync` 6-hour cap on API 35+ did exactly that, and even
|
||||||
|
* the `specialUse` type we now use will be subject to any future platform time
|
||||||
|
* limit. When that happens the system calls
|
||||||
|
* [AlarmForegroundService.onTimeout] and the service stops — the socket is
|
||||||
|
* gone, but a stale "armed" claim would keep telling the user otherwise. That
|
||||||
|
* is the worst possible failure for this app: the user believes they are
|
||||||
|
* covered and silently is not.
|
||||||
|
*
|
||||||
|
* The service therefore writes its real state here, and the JS layer reads it
|
||||||
|
* back through `AlarmModule.getServiceState()` so the UI can say
|
||||||
|
* "this device stopped listening" instead of pretending to be armed.
|
||||||
|
*
|
||||||
|
* Stored in SharedPreferences (not in memory) because the service can be killed
|
||||||
|
* while the app is not running; the next app open must still find the record.
|
||||||
|
*/
|
||||||
|
object AlarmListeningState {
|
||||||
|
|
||||||
|
private const val PREFS = "alarm.listening.v1"
|
||||||
|
private const val KEY_STATE = "state"
|
||||||
|
private const val KEY_SINCE = "since"
|
||||||
|
private const val KEY_ALERT = "alertId"
|
||||||
|
|
||||||
|
/** The armed socket is open (or being reconnected) in the foreground service. */
|
||||||
|
const val STATE_RUNNING = "running"
|
||||||
|
|
||||||
|
/** No service is meant to be running: the user disarmed, left or stopped it. */
|
||||||
|
const val STATE_IDLE = "idle"
|
||||||
|
|
||||||
|
/** The OS hit a foreground-service time limit; listening stopped without the user asking. */
|
||||||
|
const val STATE_TIMED_OUT = "stopped-timeout"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The socket delivered a trigger but the alarm could not be scheduled (exact
|
||||||
|
* alarms revoked at runtime, or `setAlarmClock` refused). The device is
|
||||||
|
* listening yet unable to actually ring — a blocking error, not a degraded
|
||||||
|
* mode.
|
||||||
|
*/
|
||||||
|
const val STATE_DELIVERY_FAILED = "delivery-failed"
|
||||||
|
|
||||||
|
data class Snapshot(val state: String, val sinceMillis: Long, val alertId: String?)
|
||||||
|
|
||||||
|
fun markRunning(context: Context, alertId: String) {
|
||||||
|
write(context, STATE_RUNNING, alertId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markIdle(context: Context) {
|
||||||
|
write(context, STATE_IDLE, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records a system-imposed stop. Deliberately *not* [STATE_IDLE]: idle means
|
||||||
|
* "nothing was supposed to be listening", which would let the UI claim the
|
||||||
|
* user is covered when the system stopped us.
|
||||||
|
*/
|
||||||
|
fun markTimedOut(context: Context) {
|
||||||
|
val previous = snapshot(context)
|
||||||
|
write(context, STATE_TIMED_OUT, previous.alertId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markDeliveryFailed(context: Context) {
|
||||||
|
val previous = snapshot(context)
|
||||||
|
write(context, STATE_DELIVERY_FAILED, previous.alertId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun snapshot(context: Context): Snapshot {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return Snapshot(
|
||||||
|
state = prefs.getString(KEY_STATE, null) ?: STATE_IDLE,
|
||||||
|
sinceMillis = prefs.getLong(KEY_SINCE, 0L),
|
||||||
|
alertId = prefs.getString(KEY_ALERT, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun write(context: Context, state: String, alertId: String?) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit()
|
||||||
|
.putString(KEY_STATE, state)
|
||||||
|
.putLong(KEY_SINCE, System.currentTimeMillis())
|
||||||
|
.apply { if (alertId == null) remove(KEY_ALERT) else putString(KEY_ALERT, alertId) }
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,9 +33,9 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
|||||||
override fun getName(): String = NAME
|
override fun getName(): String = NAME
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun playAlarm(alertId: String, message: String, autoStopSeconds: Double, promise: Promise) {
|
fun playAlarm(alertId: String, message: String, promise: Promise) {
|
||||||
try {
|
try {
|
||||||
val volumeRaised = AlarmPlayer.play(reactContext, alertId, autoStopSeconds.toInt())
|
val volumeRaised = AlarmPlayer.play(reactContext, alertId)
|
||||||
promise.resolve(result(ok = true, volumeRaised = volumeRaised))
|
promise.resolve(result(ok = true, volumeRaised = volumeRaised))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
promise.resolve(result(ok = false, reason = e.message ?: "play-failed"))
|
promise.resolve(result(ok = false, reason = e.message ?: "play-failed"))
|
||||||
@@ -46,7 +46,7 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
|||||||
fun stopAlarm(promise: Promise) {
|
fun stopAlarm(promise: Promise) {
|
||||||
try {
|
try {
|
||||||
AlarmPlayer.stop(reactContext)
|
AlarmPlayer.stop(reactContext)
|
||||||
AlarmNotifier.cancelAlarm(reactContext)
|
AlarmNotifier.cancelVolumeWarning(reactContext)
|
||||||
promise.resolve(result(ok = true))
|
promise.resolve(result(ok = true))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
promise.resolve(result(ok = false, reason = e.message ?: "stop-failed"))
|
promise.resolve(result(ok = false, reason = e.message ?: "stop-failed"))
|
||||||
@@ -65,6 +65,17 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
|||||||
promise.resolve(result(ok = false, reason = "notifications-not-granted"))
|
promise.resolve(result(ok = false, reason = "notifications-not-granted"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A device that cannot fire a real alarm must not look armed. These
|
||||||
|
// are blocking errors, not warnings: there is no notification-only
|
||||||
|
// fallback by design.
|
||||||
|
if (!AlarmScheduler.canScheduleExactAlarms(reactContext)) {
|
||||||
|
promise.resolve(result(ok = false, reason = "exact-alarms-not-granted"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasFullScreenIntent()) {
|
||||||
|
promise.resolve(result(ok = false, reason = "full-screen-intent-not-granted"))
|
||||||
|
return
|
||||||
|
}
|
||||||
AlarmForegroundService.start(reactContext, alertId, wsUrl)
|
AlarmForegroundService.start(reactContext, alertId, wsUrl)
|
||||||
promise.resolve(result(ok = true))
|
promise.resolve(result(ok = true))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -140,10 +151,6 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
|||||||
val map = Arguments.createMap()
|
val map = Arguments.createMap()
|
||||||
map.putString("alertId", alertId)
|
map.putString("alertId", alertId)
|
||||||
map.putString("message", intent?.getStringExtra(AlarmActivity.EXTRA_MESSAGE) ?: "")
|
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)
|
intent?.removeExtra(AlarmActivity.EXTRA_ALERT_ID)
|
||||||
promise.resolve(map)
|
promise.resolve(map)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -156,6 +163,60 @@ class AlarmModule(private val reactContext: ReactApplicationContext) :
|
|||||||
promise.resolve(isBatteryUnrestricted())
|
promise.resolve(isBatteryUnrestricted())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the armed foreground service is really listening.
|
||||||
|
*
|
||||||
|
* `state` is one of `running` | `idle` | `stopped` | `stopped-timeout` |
|
||||||
|
* `delivery-failed`. The last two mean the OS stopped us or the alarm could
|
||||||
|
* not be scheduled: the UI must report "not covered" rather than trusting a
|
||||||
|
* stale in-memory flag, because the service can be killed while the app is
|
||||||
|
* not running.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun getServiceState(promise: Promise) {
|
||||||
|
try {
|
||||||
|
val snapshot = AlarmListeningState.snapshot(reactContext)
|
||||||
|
val map = Arguments.createMap()
|
||||||
|
map.putString("state", snapshot.state)
|
||||||
|
map.putDouble("since", snapshot.sinceMillis.toDouble())
|
||||||
|
snapshot.alertId?.let { map.putString("alertId", it) }
|
||||||
|
promise.resolve(map)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
val map = Arguments.createMap()
|
||||||
|
map.putString("state", "unknown")
|
||||||
|
map.putDouble("since", 0.0)
|
||||||
|
promise.resolve(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cryptographically random lowercase hex token, exactly [chars] characters.
|
||||||
|
*
|
||||||
|
* Backed by `java.security.SecureRandom` — **not** `Math.random()`. The JS
|
||||||
|
* layer uses this for the per-install device id, which is the user's only
|
||||||
|
* pseudonymous identity on the alarm service; a predictable id would let
|
||||||
|
* anyone enumerate or impersonate subscriptions. No hardware identifier is
|
||||||
|
* read on this path: the value is pure randomness, stored locally.
|
||||||
|
*/
|
||||||
|
@ReactMethod
|
||||||
|
fun randomToken(chars: Double, promise: Promise) {
|
||||||
|
try {
|
||||||
|
val length = chars.toInt().coerceIn(8, 64)
|
||||||
|
val bytes = ByteArray((length + 1) / 2)
|
||||||
|
java.security.SecureRandom().nextBytes(bytes)
|
||||||
|
val hex = "0123456789abcdef"
|
||||||
|
val builder = StringBuilder(length)
|
||||||
|
for (b in bytes) {
|
||||||
|
val v = b.toInt() and 0xff
|
||||||
|
builder.append(hex[v ushr 4])
|
||||||
|
if (builder.length < length) builder.append(hex[v and 0x0f])
|
||||||
|
}
|
||||||
|
promise.resolve(builder.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
promise.resolve(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
fun openFullScreenIntentSettings(promise: Promise) {
|
fun openFullScreenIntentSettings(promise: Promise) {
|
||||||
openSettings(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)
|
openSettings(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ object AlarmPlayer {
|
|||||||
private val VIBRATION_PATTERN = longArrayOf(0L, 700L, 400L)
|
private val VIBRATION_PATTERN = longArrayOf(0L, 700L, 400L)
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun play(context: Context, alertId: String, autoStopSeconds: Int): Boolean {
|
fun play(context: Context, alertId: String): Boolean {
|
||||||
stop(context)
|
stop(context)
|
||||||
val volumeRaised = raiseAlarmStream(context)
|
val volumeRaised = raiseAlarmStream(context)
|
||||||
|
|
||||||
@@ -62,7 +62,13 @@ object AlarmPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startVibration(context)
|
startVibration(context)
|
||||||
AlarmNotifier.showAlarmNotification(context, alertId, volumeRaised, autoStopSeconds)
|
// The only notification this app posts about a firing alarm: the volume
|
||||||
|
// warning. Everything else is the real alarm (sound + takeover).
|
||||||
|
if (!volumeRaised) {
|
||||||
|
AlarmNotifier.showVolumeWarning(context, alertId, alarmVolumePercent(context))
|
||||||
|
} else {
|
||||||
|
AlarmNotifier.cancelVolumeWarning(context)
|
||||||
|
}
|
||||||
return volumeRaised
|
return volumeRaised
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +112,22 @@ object AlarmPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Current alarm-stream volume as a percentage, for the honest warning copy
|
||||||
|
* ("Alarm volume is at 40%"). Returns 100 when the value cannot be read, so
|
||||||
|
* the warning never invents a number it does not have.
|
||||||
|
*/
|
||||||
|
fun alarmVolumePercent(context: Context): Int {
|
||||||
|
val am = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return 100
|
||||||
|
return try {
|
||||||
|
val max = am.getStreamMaxVolume(AudioManager.STREAM_ALARM)
|
||||||
|
if (max <= 0) return 100
|
||||||
|
(am.getStreamVolume(AudioManager.STREAM_ALARM) * 100 / max).coerceIn(0, 100)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun startVibration(context: Context) {
|
private fun startVibration(context: Context) {
|
||||||
val v: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
val v: Vibrator? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
(context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator
|
||||||
@@ -128,101 +150,96 @@ object AlarmPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notification channels + the alarm notification, including the volume fallback. */
|
/**
|
||||||
|
* The only two notifications this app is allowed to post.
|
||||||
|
*
|
||||||
|
* Product ruling: a notification is never a delivery path. An alert fires as a
|
||||||
|
* real alarm (sound + takeover through [AlarmScheduler]). Notifications exist
|
||||||
|
* only for
|
||||||
|
* 1. the **volume warning** — "your alarm volume is not at maximum", which the
|
||||||
|
* user explicitly allows and which tells them something true and useful, and
|
||||||
|
* 2. the **foreground-service notification** — mandatory while the armed
|
||||||
|
* socket is held; given the quietest treatment the platform offers
|
||||||
|
* (IMPORTANCE_MIN, no sound, no vibration, no badge, no heads-up) because
|
||||||
|
* it is a legal requirement, not a delivery mechanism.
|
||||||
|
*/
|
||||||
object AlarmNotifier {
|
object AlarmNotifier {
|
||||||
const val CHANNEL_ALARM = "alarm_trigger"
|
|
||||||
|
/** Volume warning. High importance so it is seen, but it is not the alarm. */
|
||||||
|
const val CHANNEL_VOLUME = "alarm_volume_warning"
|
||||||
|
|
||||||
|
/** Foreground-service presence. Deliberately the quietest channel. */
|
||||||
const val CHANNEL_SERVICE = "alarm_armed"
|
const val CHANNEL_SERVICE = "alarm_armed"
|
||||||
|
|
||||||
fun ensureChannels(context: Context) {
|
fun ensureChannels(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
|
||||||
val alarm = NotificationChannel(
|
val volume = NotificationChannel(
|
||||||
CHANNEL_ALARM,
|
CHANNEL_VOLUME,
|
||||||
"Alarm triggers",
|
"Alarm volume",
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
).apply {
|
).apply {
|
||||||
description = "Fires at full volume when an alert is triggered"
|
description = "Tells you when the alarm volume is not at maximum, so the alert can be heard"
|
||||||
enableVibration(true)
|
enableVibration(false)
|
||||||
setShowBadge(true)
|
setShowBadge(true)
|
||||||
// The alarm is the payload: bypass DND where the user allows it.
|
|
||||||
setBypassDnd(true)
|
|
||||||
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
|
||||||
}
|
}
|
||||||
nm.createNotificationChannel(alarm)
|
nm.createNotificationChannel(volume)
|
||||||
|
|
||||||
val service = NotificationChannel(
|
val service = NotificationChannel(
|
||||||
CHANNEL_SERVICE,
|
CHANNEL_SERVICE,
|
||||||
"Armed alert",
|
"Armed alert",
|
||||||
NotificationManager.IMPORTANCE_LOW
|
NotificationManager.IMPORTANCE_MIN
|
||||||
).apply {
|
).apply {
|
||||||
description = "Keeps the connection to the alarm service open while armed"
|
description = "Required while the app holds the armed connection; it makes no sound"
|
||||||
setShowBadge(false)
|
setShowBadge(false)
|
||||||
|
enableVibration(false)
|
||||||
|
setSound(null, null)
|
||||||
|
lockscreenVisibility = android.app.Notification.VISIBILITY_SECRET
|
||||||
}
|
}
|
||||||
nm.createNotificationChannel(service)
|
nm.createNotificationChannel(service)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-priority alarm notification. When `volumeRaised` is false the
|
* "Alarm volume is at 40% — raise it to maximum so this alert can be heard."
|
||||||
* notification carries a "Raise your volume" action, because the OS refused
|
* No full-screen intent, no "an alarm is ringing": this is informational.
|
||||||
* the forced alarm stream and the user has to do it.
|
|
||||||
*/
|
*/
|
||||||
fun showAlarmNotification(
|
fun showVolumeWarning(context: Context, alertId: String, volumePercent: Int) {
|
||||||
context: Context,
|
|
||||||
alertId: String,
|
|
||||||
volumeRaised: Boolean,
|
|
||||||
autoStopSeconds: Int
|
|
||||||
) {
|
|
||||||
ensureChannels(context)
|
ensureChannels(context)
|
||||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val raise = android.app.PendingIntent.getActivity(
|
||||||
val open = android.app.PendingIntent.getActivity(
|
|
||||||
context,
|
context,
|
||||||
alertId.hashCode(),
|
alertId.hashCode() + 1,
|
||||||
AlarmActivity.intent(context, alertId, "", autoStopSeconds),
|
android.content.Intent(android.provider.Settings.ACTION_SOUND_SETTINGS),
|
||||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
|
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
|
val builder = android.app.Notification.Builder(context, CHANNEL_VOLUME)
|
||||||
val builder = android.app.Notification.Builder(context, CHANNEL_ALARM)
|
.setContentTitle("Alarm volume is at $volumePercent%")
|
||||||
.setContentTitle("Alarm")
|
.setContentText("Raise it to maximum so this alert can be heard.")
|
||||||
.setContentText(if (volumeRaised) "Alarm ringing" else "Raise your volume to hear the alarm")
|
|
||||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
||||||
.setCategory(android.app.Notification.CATEGORY_ALARM)
|
.setCategory(android.app.Notification.CATEGORY_RECOMMENDATION)
|
||||||
.setPriority(android.app.Notification.PRIORITY_MAX)
|
.setAutoCancel(true)
|
||||||
.setAutoCancel(false)
|
.setOnlyAlertOnce(true)
|
||||||
.setOngoing(true)
|
.setContentIntent(raise)
|
||||||
.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 {
|
try {
|
||||||
nm.notify(NOTIFICATION_ID_ALARM, builder.build())
|
nm.notify(NOTIFICATION_ID_VOLUME, builder.build())
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
|
Log.w("AlarmNotifier", "notify failed (POST_NOTIFICATIONS?): ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun cancelAlarm(context: Context) {
|
fun cancelVolumeWarning(context: Context) {
|
||||||
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
nm.cancel(NOTIFICATION_ID_ALARM)
|
nm.cancel(NOTIFICATION_ID_VOLUME)
|
||||||
}
|
}
|
||||||
|
|
||||||
const val NOTIFICATION_ID_ALARM = 4711
|
/** Removes the persistent "Alarm armed" notification. */
|
||||||
|
fun cancelArmed(context: Context) {
|
||||||
|
val nm = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
nm.cancel(NOTIFICATION_ID_SERVICE)
|
||||||
|
}
|
||||||
|
|
||||||
|
const val NOTIFICATION_ID_VOLUME = 4711
|
||||||
const val NOTIFICATION_ID_SERVICE = 4712
|
const val NOTIFICATION_ID_SERVICE = 4712
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a **real alarm** through [AlarmManager.setAlarmClock].
|
||||||
|
*
|
||||||
|
* ## Why not just `startActivity`
|
||||||
|
*
|
||||||
|
* Android 14 (API 34) made `USE_FULL_SCREEN_INTENT` a restricted permission:
|
||||||
|
* the system grants it automatically only to apps it classifies as call/alarm
|
||||||
|
* apps, and revokes it for everyone else. The platform's own answer to "what is
|
||||||
|
* an alarm app" is exact-alarm behaviour, and `setAlarmClock` is the strongest
|
||||||
|
* form of it — it is the API the Clock app uses. Firing the alarm this way is
|
||||||
|
* therefore both the delivery mechanism *and* the thing that earns the
|
||||||
|
* classification. A notification is never a delivery path in this app: the only
|
||||||
|
* notification we post about a firing alert is the volume warning.
|
||||||
|
*
|
||||||
|
* It is also more robust than starting the activity directly: the alarm lives in
|
||||||
|
* `AlarmManager`, so it still fires if the JS layer or our service died between
|
||||||
|
* the trigger frame arriving and the alarm going off.
|
||||||
|
*
|
||||||
|
* ## Standing entry
|
||||||
|
*
|
||||||
|
* While an alert is armed we also keep a recurring alarm-clock entry
|
||||||
|
* ([holdStanding]). Two reasons: the classification above may be evaluated at a
|
||||||
|
* moment other than "while posting the notification", and a standing entry is
|
||||||
|
* the honest status-bar "this device is armed" indicator. The same wake is the
|
||||||
|
* re-arm point if the service is ever stopped by the platform.
|
||||||
|
*
|
||||||
|
* **Unverified on hardware:** that a standing alarm clock actually causes
|
||||||
|
* `canUseFullScreenIntent()` to return true (or that scheduling lifts the
|
||||||
|
* restriction at all). That chain comes from platform documentation, not from a
|
||||||
|
* measurement on a real API 34/35/36 device.
|
||||||
|
*/
|
||||||
|
object AlarmScheduler {
|
||||||
|
|
||||||
|
private const val TAG = "AlarmScheduler"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger-to-alarm scheduling margin.
|
||||||
|
*
|
||||||
|
* `setAlarmClock` is exact: there is no bucket to round up to, so the only
|
||||||
|
* question is how far in the future the trigger time must be. `now + 0` can
|
||||||
|
* land in the past by the time the binder call is serviced (clock ticks and
|
||||||
|
* some ROMs treat a non-future trigger as already fired), so we keep a
|
||||||
|
* small margin. 250 ms is above any plausible service latency and far below
|
||||||
|
* a perceptible delay — and it is deliberately *not* a fixed 1 s "so the OS
|
||||||
|
* notices": that was an arbitrary round-up, not a platform requirement.
|
||||||
|
*
|
||||||
|
* Trial on hardware is still the final word on the smallest safe value; the
|
||||||
|
* budget we report is socket (p50 8 ms, max 23 ms measured) + this margin +
|
||||||
|
* activity launch.
|
||||||
|
*/
|
||||||
|
private const val FIRE_DELAY_MS = 250L
|
||||||
|
|
||||||
|
/** Standing self-check cadence. Also the re-arm point if the service stops. */
|
||||||
|
private const val STANDING_INTERVAL_MS = 30 * 60 * 1000L
|
||||||
|
|
||||||
|
/** After a reboot, re-arm almost immediately (a boot is already a state change). */
|
||||||
|
private const val BOOT_DELAY_MS = 60 * 1000L
|
||||||
|
|
||||||
|
private const val REQ_FIRE = 4201
|
||||||
|
private const val REQ_STANDING = 4202
|
||||||
|
private const val REQ_SHOW = 4203
|
||||||
|
|
||||||
|
const val ACTION_STANDING = "com.dcntrlized.alarm.STANDING"
|
||||||
|
const val EXTRA_ALERT_ID = "alarm.scheduler.alertId"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `USE_EXACT_ALARM` (declared in the manifest) makes this true without user
|
||||||
|
* action on API 33+, which is the point of off-Play distribution. On API
|
||||||
|
* 31/32 only `SCHEDULE_EXACT_ALARM` applies and the user grant is required.
|
||||||
|
*/
|
||||||
|
fun canScheduleExactAlarms(context: Context): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
||||||
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
|
||||||
|
return am.canScheduleExactAlarms()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the takeover [FIRE_DELAY_MS] from now. Returns false (never
|
||||||
|
* falls back to a notification) when the OS refuses: the caller records a
|
||||||
|
* delivery failure that the UI must surface.
|
||||||
|
*/
|
||||||
|
fun fireNow(context: Context, alertId: String, message: String): Boolean {
|
||||||
|
if (!canScheduleExactAlarms(context)) {
|
||||||
|
Log.w(TAG, "cannot fire: exact alarms not allowed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
|
||||||
|
return try {
|
||||||
|
val operation = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
REQ_FIRE,
|
||||||
|
AlarmActivity.intent(context, alertId, message),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
am.setAlarmClock(
|
||||||
|
AlarmManager.AlarmClockInfo(
|
||||||
|
System.currentTimeMillis() + FIRE_DELAY_MS,
|
||||||
|
showIntent(context)
|
||||||
|
),
|
||||||
|
operation
|
||||||
|
)
|
||||||
|
Log.i(TAG, "alarm clock scheduled for $alertId")
|
||||||
|
true
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
// The documented failure when exact alarms are denied.
|
||||||
|
Log.w(TAG, "setAlarmClock denied: ${e.message}")
|
||||||
|
false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "setAlarmClock failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Re)creates the standing alarm-clock entry while an alert is armed.
|
||||||
|
*
|
||||||
|
* The `operation` is a broadcast to [AlarmWakeReceiver], which re-arms the
|
||||||
|
* entry and restarts the armed service when it is not running — so the
|
||||||
|
* entry is self-perpetuating and survives the process being killed.
|
||||||
|
*/
|
||||||
|
fun holdStanding(
|
||||||
|
context: Context,
|
||||||
|
alertId: String,
|
||||||
|
delayMs: Long = STANDING_INTERVAL_MS
|
||||||
|
): Boolean {
|
||||||
|
if (!canScheduleExactAlarms(context)) {
|
||||||
|
Log.w(TAG, "cannot hold standing entry: exact alarms not allowed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return false
|
||||||
|
return try {
|
||||||
|
val operation = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
REQ_STANDING,
|
||||||
|
Intent(context, AlarmWakeReceiver::class.java)
|
||||||
|
.setAction(ACTION_STANDING)
|
||||||
|
.putExtra(EXTRA_ALERT_ID, alertId),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
am.setAlarmClock(
|
||||||
|
AlarmManager.AlarmClockInfo(System.currentTimeMillis() + delayMs, showIntent(context)),
|
||||||
|
operation
|
||||||
|
)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "standing setAlarmClock failed: ${e.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuilds the standing entry after a reboot without restarting the socket. */
|
||||||
|
fun rearmAfterBoot(context: Context): Boolean {
|
||||||
|
val armed = AlarmArmStore.current(context) ?: return false
|
||||||
|
return holdStanding(context, armed.alertId, BOOT_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancels only the one-shot firing entry.
|
||||||
|
*
|
||||||
|
* Used on **disarm**: if a trigger frame scheduled the takeover a second ago
|
||||||
|
* and the owner disarms in that window, the alarm must not ring afterwards.
|
||||||
|
* The standing entry deliberately survives, because a disarm ends the
|
||||||
|
* ringing, not the subscription — the device is still armed and can still be
|
||||||
|
* triggered, so its alarm-app status must not disappear.
|
||||||
|
*/
|
||||||
|
fun cancelFire(context: Context) {
|
||||||
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||||
|
try {
|
||||||
|
am.cancel(
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
REQ_FIRE,
|
||||||
|
AlarmActivity.intent(context, "", ""),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "cancelFire failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes both alarm-clock entries. Called on explicit stop, unsubscribe and
|
||||||
|
* alert deletion — otherwise the user keeps a permanent alarm icon for an
|
||||||
|
* alert they no longer follow.
|
||||||
|
*/
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
cancelFire(context)
|
||||||
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||||
|
try {
|
||||||
|
// PendingIntent identity ignores extras, so this matches the entry
|
||||||
|
// created in holdStanding. cancel() on an absent alarm is a no-op.
|
||||||
|
am.cancel(
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
REQ_STANDING,
|
||||||
|
Intent(context, AlarmWakeReceiver::class.java).setAction(ACTION_STANDING),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "cancel failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the standing "armed" entry is present in the OS. */
|
||||||
|
fun hasStanding(context: Context): Boolean = try {
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
REQ_STANDING,
|
||||||
|
Intent(context, AlarmWakeReceiver::class.java).setAction(ACTION_STANDING),
|
||||||
|
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
) != null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showIntent(context: Context): PendingIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
REQ_SHOW,
|
||||||
|
Intent(context, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.dcntrlized.alarm
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the standing alarm-clock entry alive and re-arms the armed service.
|
||||||
|
*
|
||||||
|
* Two responsibilities:
|
||||||
|
* - **After a reboot** (`BOOT_COMPLETED`/`LOCKED_BOOT_COMPLETED`): rebuild the
|
||||||
|
* standing alarm-clock entry, so the OS still sees this app as holding an
|
||||||
|
* alarm. Starting a foreground service straight from boot is restricted on
|
||||||
|
* Android 12+, so we only schedule here — the exact-alarm broadcast a minute
|
||||||
|
* later is allowed to start the service, and that path does it.
|
||||||
|
* - **On the standing wake**: re-arm the entry (it is one-shot, so it must
|
||||||
|
* chain itself) and restart the armed socket if it is not running. This is
|
||||||
|
* also the re-arm point if the platform stops the service.
|
||||||
|
*
|
||||||
|
* Everything here is best-effort and recorded honestly: a missing entry is
|
||||||
|
* visible as a delivery problem in the app, never silently assumed away.
|
||||||
|
*/
|
||||||
|
class AlarmWakeReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
when (intent?.action) {
|
||||||
|
Intent.ACTION_BOOT_COMPLETED,
|
||||||
|
Intent.ACTION_LOCKED_BOOT_COMPLETED,
|
||||||
|
"android.intent.action.QUICKBOOT_POWERON" -> {
|
||||||
|
val armed = AlarmArmStore.current(context) ?: return
|
||||||
|
val ok = AlarmScheduler.rearmAfterBoot(context)
|
||||||
|
Log.i(TAG, "boot: standing entry re-armed=$ok for ${armed.alertId}")
|
||||||
|
}
|
||||||
|
|
||||||
|
AlarmScheduler.ACTION_STANDING -> {
|
||||||
|
val armed = AlarmArmStore.current(context) ?: return
|
||||||
|
// Chain the next wake before doing anything that could fail.
|
||||||
|
AlarmScheduler.holdStanding(context, armed.alertId)
|
||||||
|
if (!AlarmForegroundService.isRunning) {
|
||||||
|
Log.i(TAG, "standing wake: restarting armed service for ${armed.alertId}")
|
||||||
|
AlarmForegroundService.start(context, armed.alertId, armed.wsUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "AlarmWakeReceiver"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { NativeModules, Platform } from 'react-native';
|
|||||||
*
|
*
|
||||||
* Every call is defensive: on iOS, in a JS-only test run, or on a build where
|
* Every call is defensive: on iOS, in a JS-only test run, or on a build where
|
||||||
* the native module failed to register, the promise resolves to a "unavailable"
|
* the native module failed to register, the promise resolves to a "unavailable"
|
||||||
* result instead of throwing. The UI must degrade to in-app sound + a
|
* result instead of throwing, and the UI must say what it can actually do
|
||||||
* high-priority notification, and say so, rather than break.
|
* (in-app sound only) rather than invent a delivery path.
|
||||||
*/
|
*/
|
||||||
export interface AlarmNativeResult {
|
export interface AlarmNativeResult {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -22,7 +22,6 @@ export interface AlarmNativeResult {
|
|||||||
export interface AlarmLaunchAlert {
|
export interface AlarmLaunchAlert {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
message: string;
|
message: string;
|
||||||
autoStopSeconds: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AlarmPermissions {
|
export interface AlarmPermissions {
|
||||||
@@ -34,14 +33,32 @@ export interface AlarmPermissions {
|
|||||||
canForceVolume: boolean;
|
canForceVolume: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the armed foreground service is actually listening.
|
||||||
|
*
|
||||||
|
* `stopped-timeout` is the important one: Android's background limits (the
|
||||||
|
* a platform time limit, e.g. the `dataSync` 6-hour cap on API 35+) stopped the
|
||||||
|
* socket without the user asking,
|
||||||
|
* so the app must not keep claiming the device is armed.
|
||||||
|
*/
|
||||||
|
export interface AlarmServiceState {
|
||||||
|
state: 'running' | 'idle' | 'stopped' | 'stopped-timeout' | 'delivery-failed' | 'unknown';
|
||||||
|
/** Epoch millis of the last state change, or 0 when unknown. */
|
||||||
|
since: number;
|
||||||
|
/** The alert the service was holding, when it recorded one. */
|
||||||
|
alertId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface AlarmNativeModule {
|
interface AlarmNativeModule {
|
||||||
playAlarm(alertId: string, message: string, autoStopSeconds: number): Promise<AlarmNativeResult>;
|
playAlarm(alertId: string, message: string): Promise<AlarmNativeResult>;
|
||||||
stopAlarm(): Promise<AlarmNativeResult>;
|
stopAlarm(): Promise<AlarmNativeResult>;
|
||||||
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
startService(alertId: string, subscribeToken: string, wsUrl: string): Promise<AlarmNativeResult>;
|
||||||
stopService(): Promise<AlarmNativeResult>;
|
stopService(): Promise<AlarmNativeResult>;
|
||||||
getPermissions(): Promise<AlarmPermissions>;
|
getPermissions(): Promise<AlarmPermissions>;
|
||||||
requestPermission(name: string): Promise<AlarmPermissions>;
|
requestPermission(name: string): Promise<AlarmPermissions>;
|
||||||
getLaunchAlert(): Promise<AlarmLaunchAlert | null>;
|
getLaunchAlert(): Promise<AlarmLaunchAlert | null>;
|
||||||
|
getServiceState(): Promise<AlarmServiceState>;
|
||||||
|
randomToken(chars: number): Promise<string | null>;
|
||||||
isIgnoringBatteryOptimizations(): Promise<boolean>;
|
isIgnoringBatteryOptimizations(): Promise<boolean>;
|
||||||
openSoundSettings(): Promise<AlarmNativeResult>;
|
openSoundSettings(): Promise<AlarmNativeResult>;
|
||||||
openAppSettings(): Promise<AlarmNativeResult>;
|
openAppSettings(): Promise<AlarmNativeResult>;
|
||||||
@@ -66,11 +83,10 @@ const PERMISSIONS_UNAVAILABLE: AlarmPermissions = {
|
|||||||
export async function playAlarm(
|
export async function playAlarm(
|
||||||
alertId: string,
|
alertId: string,
|
||||||
message = '',
|
message = '',
|
||||||
autoStopSeconds = 0,
|
|
||||||
): Promise<AlarmNativeResult> {
|
): Promise<AlarmNativeResult> {
|
||||||
if (!native) return UNAVAILABLE;
|
if (!native) return UNAVAILABLE;
|
||||||
try {
|
try {
|
||||||
return await native.playAlarm(alertId, message, autoStopSeconds);
|
return await native.playAlarm(alertId, message);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, reason: e instanceof Error ? e.message : 'play-failed' };
|
return { ok: false, reason: e instanceof Error ? e.message : 'play-failed' };
|
||||||
}
|
}
|
||||||
@@ -145,6 +161,34 @@ export async function requestPermission(name: string): Promise<AlarmPermissions>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads whether the armed foreground service is really listening. On a build
|
||||||
|
* without the native module (iOS, tests) this reports `unknown`, which the UI
|
||||||
|
* treats as "cannot confirm" — never as "covered".
|
||||||
|
*/
|
||||||
|
export async function getServiceState(): Promise<AlarmServiceState> {
|
||||||
|
if (!native) return { state: 'unknown', since: 0 };
|
||||||
|
try {
|
||||||
|
return await native.getServiceState();
|
||||||
|
} catch {
|
||||||
|
return { state: 'unknown', since: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cryptographically random lowercase hex token (`SecureRandom` on Android).
|
||||||
|
* Returns `null` when the native module is unavailable — callers must fail
|
||||||
|
* loudly rather than fall back to a non-cryptographic source.
|
||||||
|
*/
|
||||||
|
export async function randomToken(chars: number): Promise<string | null> {
|
||||||
|
if (!native) return null;
|
||||||
|
try {
|
||||||
|
return await native.randomToken(chars);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Opens the system volume UI — the takeover's volume-blocked recovery action. */
|
/** Opens the system volume UI — the takeover's volume-blocked recovery action. */
|
||||||
export async function openSoundSettings(): Promise<AlarmNativeResult> {
|
export async function openSoundSettings(): Promise<AlarmNativeResult> {
|
||||||
if (!native) return UNAVAILABLE;
|
if (!native) return UNAVAILABLE;
|
||||||
|
|||||||
Reference in New Issue
Block a user