Phase 4: the phone's Kotlin ring path (item 4)

Hand-written Kotlin behind ONE MethodChannel, never a plugin: AGP 9.0.1 /
Gradle 9.1 / Kotlin 2.3.20 is bleeding edge and a plugin that will not compile
breaks the build outright rather than degrading.

- AlarmScheduler: setAlarmClock() (Doze-exempt, no SCHEDULE_EXACT_ALARM
  prompt), persists the time so boot and dismiss can re-arm.
- AlarmReceiver: hands straight to the service — a receiver gets ~10s and no
  wakelock, so nothing that must outlive it belongs there.
- AlarmService: foreground service, PARTIAL_WAKE_LOCK, USAGE_ALARM
  (= STREAM_ALARM, which is what ignores DND and silent mode), looping
  ringtone, vibration, full-screen intent over the lockscreen.
- AlarmActivity: showWhenLocked + turnScreenOn, routes to the Dart ring
  screen, back is a no-op so the alarm cannot be escaped.
- BootReceiver: re-arms on BOOT_COMPLETED; AlarmManager drops alarms across a
  reboot.
- StepCounter: TYPE_STEP_COUNTER baseline for item 3's fallback proof, also
  hand-written to keep plugin risk off this path.

Details that are a hard crash or a silent no-ring rather than a warning:
FLAG_IMMUTABLE on every PendingIntent (mandatory since API 31);
foregroundServiceType declared on the <service> AND passed to
startForeground(); explicit android:exported on every component;
USE_FULL_SCREEN_INTENT declared (missing from the plan's list) and
canUseFullScreenIntent() exposed so the Dart side can gate rather than warn.

Every permission verified present in android-36's api-versions.xml rather
than from recall. Compiles green with `flutter build apk --debug`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
This commit is contained in:
Krzysztof Rudnicki 2026-08-27 21:12:51 +02:00
parent 738027d527
commit e0cc20664c
9 changed files with 597 additions and 1 deletions

View File

@ -1,6 +1,24 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="com.android.alarm.permission.SET_ALARM"/>
<!-- Alarm-clock app: USE_EXACT_ALARM is granted at install with no user
prompt, unlike SCHEDULE_EXACT_ALARM. setAlarmClock() is what earns it. -->
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- The full-screen intent is what puts the dismiss screen over the
lockscreen; from API 34 this permission gates it and can still be
revoked, so the app checks canUseFullScreenIntent() at schedule time. -->
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.TURN_SCREEN_ON"/>
<uses-permission android:name="android.permission.DISABLE_KEYGUARD"/>
<!-- Room proof (item 3): steps are the fallback, NFC the real proof. -->
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
<uses-permission android:name="android.permission.NFC"/>
<application
android:label="wake_alarm_sync"
android:name="${applicationName}"
@ -23,6 +41,35 @@
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- The dismiss screen, launched by the full-screen intent. Not
exported: only this app's own PendingIntent may start it. -->
<activity
android:name=".AlarmActivity"
android:exported="false"
android:launchMode="singleInstance"
android:taskAffinity=".alarm"
android:excludeFromRecents="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"/>
<!-- foregroundServiceType must ALSO be passed to startForeground(); on
API 34+ omitting either is a hard crash the moment the alarm fires. -->
<service
android:name=".AlarmService"
android:exported="false"
android:foregroundServiceType="mediaPlayback"/>
<receiver
android:name=".AlarmReceiver"
android:exported="false"/>
<receiver
android:name=".BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<meta-data
android:name="flutterEmbedding"
android:value="2" />

View File

@ -0,0 +1,52 @@
package com.kuhy.wake_alarm_sync
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/**
* The dismiss screen, launched by the full-screen intent over the lockscreen.
*
* A separate FlutterActivity from [MainActivity] so the ring UI can never be
* confused with the normal app task: it is launched with CLEAR_TASK and routes
* straight to the Dart ring screen.
*/
class AlarmActivity : FlutterActivity() {
/** Dart route the ring screen is registered under in main.dart. */
private val ringRoute = "/ring"
override fun getInitialRoute(): String = ringRoute
override fun onCreate(savedInstanceState: Bundle?) {
// showWhenLocked/turnScreenOn are also set as manifest attributes; the
// programmatic calls cover the case where the activity is re-created
// while the device is already locked.
setShowWhenLocked(true)
setTurnScreenOn(true)
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
AlarmChannel.register(this, flutterEngine)
}
/**
* Dismissing is the only way out: the ring screen owns the challenge, so
* back must not silently drop the user onto the lockscreen with the alarm
* still sounding.
*/
@Suppress("MissingSuperCall")
override fun onBackPressed() = Unit
companion object {
/** Channel name shared with [MainActivity]; one channel for the whole feature. */
const val CHANNEL: String = "com.kuhy.wake_alarm_sync/alarm"
/** Convenience for tests and callers that need the MethodChannel type. */
fun channel(engine: FlutterEngine): MethodChannel =
MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL)
}
}

View File

@ -0,0 +1,62 @@
package com.kuhy.wake_alarm_sync
import android.content.Context
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
/**
* The single MethodChannel this feature exposes to Dart.
*
* One channel rather than a plugin: the stack is AGP 9 / Gradle 9.1 /
* Kotlin 2.3.20, and a plugin that fails to compile breaks the build outright
* instead of degrading. Everything the alarm needs is here.
*/
object AlarmChannel {
/** Wires [engine]'s Dart side to the scheduler and the ring service. */
fun register(context: Context, engine: FlutterEngine) {
val app = context.applicationContext
MethodChannel(engine.dartExecutor.binaryMessenger, AlarmActivity.CHANNEL)
.setMethodCallHandler { call, result -> handle(app, call, result) }
}
private fun handle(context: Context, call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"schedule" -> schedule(context, call, result)
"cancel" -> {
AlarmScheduler.cancel(context)
result.success(null)
}
// Called from the Dart init seam and after a dismiss: with no
// change-stream in the store, these are the only re-arm hooks
// besides BOOT_COMPLETED.
"rearm" -> result.success(AlarmScheduler.rearmFromStorage(context))
"canUseFullScreenIntent" ->
result.success(AlarmScheduler.canUseFullScreenIntent(context))
"dismiss" -> {
AlarmService.stop(context)
// Re-arm immediately so the alarm rings again tomorrow.
AlarmScheduler.rearmFromStorage(context)
result.success(null)
}
"stepCount" -> result.success(StepCounter.stepsSinceRingStart(context))
"beginStepCount" -> {
StepCounter.markRingStart(context)
result.success(null)
}
else -> result.notImplemented()
}
}
private fun schedule(context: Context, call: MethodCall, result: MethodChannel.Result) {
val hour = call.argument<Int>("hour")
val minute = call.argument<Int>("minute")
if (hour == null || minute == null) {
result.error("bad_args", "hour and minute are required", null)
return
}
AlarmScheduler.schedule(context, hour, minute)
result.success(null)
}
}

View File

@ -0,0 +1,20 @@
package com.kuhy.wake_alarm_sync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Receives the exact alarm and hands straight off to [AlarmService].
*
* Deliberately does no work of its own: a receiver gets ~10s and no wakelock,
* so anything that must outlive it (sound, the wakelock, the full-screen
* intent) belongs to the foreground service. Starting that service from here
* is legal with the screen off only because setAlarmClock grants an
* alarm-clock exemption to the broadcast.
*/
class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
AlarmService.start(context)
}
}

View File

@ -0,0 +1,118 @@
package com.kuhy.wake_alarm_sync
import android.app.AlarmManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import java.util.Calendar
/**
* Arms and cancels the one exact alarm this app owns.
*
* Uses [AlarmManager.setAlarmClock] rather than setExactAndAllowWhileIdle: it is
* the only API that is Doze-exempt *and* needs no SCHEDULE_EXACT_ALARM user
* prompt (USE_EXACT_ALARM covers an alarm-clock app), and it is what grants the
* receiver its brief foreground-service exemption when it fires with the screen
* off. Everything else about the ring path depends on that exemption.
*/
object AlarmScheduler {
/** Request code for the single alarm PendingIntent; reused so re-arming replaces. */
private const val REQUEST_CODE = 1001
/** SharedPreferences file holding the armed time, so boot can re-arm it. */
private const val PREFS = "wake_alarm_scheduler"
private const val KEY_HOUR = "hour"
private const val KEY_MINUTE = "minute"
/** Sentinel for "no alarm stored", distinguishable from a valid 0. */
private const val NONE = -1
/**
* Arms the alarm for the next occurrence of [hour]:[minute] in local time,
* and persists it so [rearmFromStorage] can restore it after a reboot.
*/
fun schedule(context: Context, hour: Int, minute: Int) {
persist(context, hour, minute)
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val at = nextOccurrence(hour, minute)
// showIntent is what the system Clock surfaces; the fire intent is separate.
manager.setAlarmClock(
AlarmManager.AlarmClockInfo(at, showIntent(context)),
firePendingIntent(context),
)
}
/** Cancels the armed alarm and forgets the stored time. */
fun cancel(context: Context) {
val manager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
manager.cancel(firePendingIntent(context))
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.remove(KEY_HOUR).remove(KEY_MINUTE).apply()
}
/**
* Re-arms from the persisted time, returning false when nothing was stored.
*
* Called on BOOT_COMPLETED and after a dismiss: an alarm clock that only
* armed on user edit would ring exactly once.
*/
fun rearmFromStorage(context: Context): Boolean {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val hour = prefs.getInt(KEY_HOUR, NONE)
val minute = prefs.getInt(KEY_MINUTE, NONE)
if (hour == NONE || minute == NONE) return false
schedule(context, hour, minute)
return true
}
/**
* Whether the system will actually honour a full-screen intent.
*
* False from API 34 unless the user granted it. The caller must treat this
* as a hard failure rather than logging and continuing: without it the
* alarm posts a notification the sleeping user never sees.
*/
fun canUseFullScreenIntent(context: Context): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val notifications =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return notifications.canUseFullScreenIntent()
}
private fun persist(context: Context, hour: Int, minute: Int) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putInt(KEY_HOUR, hour).putInt(KEY_MINUTE, minute).apply()
}
/** Next local-time instant matching [hour]:[minute]; tomorrow if today has passed. */
private fun nextOccurrence(hour: Int, minute: Int): Long {
val now = Calendar.getInstance()
val at = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, hour)
set(Calendar.MINUTE, minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (!at.after(now)) at.add(Calendar.DAY_OF_YEAR, 1)
return at.timeInMillis
}
/** FLAG_IMMUTABLE is mandatory from API 31; without it this throws at arm time. */
private fun firePendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
Intent(context, AlarmReceiver::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
/** Tapping the clock's alarm chip opens the app rather than the ring screen. */
private fun showIntent(context: Context): PendingIntent = PendingIntent.getActivity(
context,
REQUEST_CODE + 1,
Intent(context, MainActivity::class.java),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}

View File

@ -0,0 +1,191 @@
package com.kuhy.wake_alarm_sync
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.MediaPlayer
import android.media.RingtoneManager
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
/**
* Foreground service that actually makes noise until the alarm is dismissed.
*
* Holds a PARTIAL_WAKE_LOCK so the CPU keeps running with the screen off (it
* does **not** wake the screen — [AlarmActivity]'s turnScreenOn does that), and
* posts a full-screen-intent notification that launches the dismiss screen over
* the lockscreen.
*/
class AlarmService : Service() {
private var player: MediaPlayer? = null
private var wakeLock: PowerManager.WakeLock? = null
private var vibrator: Vibrator? = null
companion object {
private const val CHANNEL_ID = "wake_alarm_ring"
private const val NOTIFICATION_ID = 42
/** Vibration: 1s buzz, 1s gap, repeating from index 0. */
private val VIBRATION_PATTERN = longArrayOf(0, 1000, 1000)
/** Starts the ring. Safe to call when already ringing — start is idempotent. */
fun start(context: Context) {
val intent = Intent(context, AlarmService::class.java)
context.startForegroundService(intent)
}
/** Stops the ring and releases every resource it holds. */
fun stop(context: Context) {
context.stopService(Intent(context, AlarmService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
createChannel()
// The type must be passed here AND declared on the <service> in the
// manifest; omitting either is a hard crash at ring time on API 34+.
startForeground(
NOTIFICATION_ID,
buildNotification(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,
)
acquireWakeLock()
startSound()
startVibration()
// START_STICKY: if the system kills us under memory pressure while the
// user is still asleep, the alarm comes back rather than going silent.
return START_STICKY
}
override fun onDestroy() {
stopSound()
stopVibration()
releaseWakeLock()
super.onDestroy()
}
private fun createChannel() {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
// IMPORTANCE_HIGH is required for a full-screen intent to be honoured.
val channel = NotificationChannel(
CHANNEL_ID,
"Wake alarm",
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = "The ringing wake alarm"
// The service owns sound and vibration, so the channel must not add
// its own — a channel sound would play on the notification stream
// and be silenced by DND, unlike STREAM_ALARM.
setSound(null, null)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
manager.createNotificationChannel(channel)
}
private fun buildNotification(): Notification {
val fullScreen = PendingIntent.getActivity(
this,
0,
Intent(this, AlarmActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Wake alarm")
.setContentText("Tap to dismiss")
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setCategory(Notification.CATEGORY_ALARM)
.setOngoing(true)
.setFullScreenIntent(fullScreen, true)
.build()
}
private fun acquireWakeLock() {
val power = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = power.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"wake_alarm_sync:ring",
).apply { acquire() }
}
private fun releaseWakeLock() {
wakeLock?.takeIf { it.isHeld }?.release()
wakeLock = null
}
private fun startSound() {
if (player != null) return
val uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE)
?: return
// USAGE_ALARM maps to STREAM_ALARM, which is what ignores DND and
// silent mode. Never USAGE_MEDIA here — it would be muted exactly when
// the alarm matters most.
player = MediaPlayer().apply {
setDataSource(this@AlarmService, uri)
setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build(),
)
isLooping = true
prepare()
start()
}
raiseAlarmVolume()
}
/** Alarm stream turned down to zero must not silence the alarm. */
private fun raiseAlarmVolume() {
val audio = getSystemService(Context.AUDIO_SERVICE) as AudioManager
val max = audio.getStreamMaxVolume(AudioManager.STREAM_ALARM)
audio.setStreamVolume(AudioManager.STREAM_ALARM, max, 0)
}
private fun stopSound() {
player?.run {
if (isPlaying) stop()
release()
}
player = null
}
private fun startVibration() {
val service = resolveVibrator() ?: return
vibrator = service
service.vibrate(
VibrationEffect.createWaveform(VIBRATION_PATTERN, 0),
)
}
private fun stopVibration() {
vibrator?.cancel()
vibrator = null
}
private fun resolveVibrator(): Vibrator? =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)
?.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
}
}

View File

@ -0,0 +1,18 @@
package com.kuhy.wake_alarm_sync
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Re-arms the stored alarm after a reboot.
*
* AlarmManager drops every alarm across a reboot, so without this the phone
* silently stops being an alarm clock until the user next edits the time.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
AlarmScheduler.rearmFromStorage(context)
}
}

View File

@ -1,5 +1,17 @@
package com.kuhy.wake_alarm_sync
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity()
/**
* The normal app entry point (the time picker).
*
* Registers the same alarm channel as [AlarmActivity] so the picker can arm,
* cancel and re-arm without going through the ring screen.
*/
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
AlarmChannel.register(this, flutterEngine)
}
}

View File

@ -0,0 +1,76 @@
package com.kuhy.wake_alarm_sync
import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
/**
* Steps taken since the alarm started ringing.
*
* Hand-written on the existing MethodChannel rather than a pub plugin, for the
* same reason the ring path is: no plugin can break the build.
*
* TYPE_STEP_COUNTER reports steps since boot, so "since ring start" is the
* difference against a baseline captured when the alarm fires. Steps are the
* *fallback* room proof only — the phone is stationary while you walk to it, so
* steps alone can be faked by shaking it in bed. NFC or QR is the real proof.
*/
object StepCounter {
private const val PREFS = "wake_alarm_steps"
private const val KEY_BASELINE = "baseline"
/** Sentinel for "no baseline captured yet". */
private const val NONE = -1L
/** How long to wait for the sensor's first reading before giving up. */
private const val READ_TIMEOUT_MS = 3000L
/** Records the current step total as the baseline for this ring. */
fun markRingStart(context: Context) {
val current = readTotal(context) ?: return
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putLong(KEY_BASELINE, current).apply()
}
/**
* Steps since [markRingStart], or -1 when unavailable.
*
* -1 (rather than 0) so the Dart side can tell "no step sensor / no
* baseline" apart from "hasn't moved yet" and fall back to another proof.
*/
fun stepsSinceRingStart(context: Context): Long {
val baseline = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getLong(KEY_BASELINE, NONE)
if (baseline == NONE) return NONE
val current = readTotal(context) ?: return NONE
return (current - baseline).coerceAtLeast(0)
}
/** Blocks briefly for one TYPE_STEP_COUNTER sample; null when there is no sensor. */
private fun readTotal(context: Context): Long? {
val manager =
context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager ?: return null
val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return null
val lock = Object()
var value: Long? = null
val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
synchronized(lock) {
value = event.values.firstOrNull()?.toLong()
lock.notifyAll()
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
}
manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_FASTEST)
synchronized(lock) {
if (value == null) lock.wait(READ_TIMEOUT_MS)
}
manager.unregisterListener(listener)
return value
}
}