mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 20:27:02 +01:00
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:
parent
738027d527
commit
e0cc20664c
@ -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" />
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user