From 66c935339c2751a5a72d4bd80ed3ddf17b5cc2f8 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 7 Nov 2026 06:36:00 +0100 Subject: [PATCH] Add an opt-in gradually louder alarm A new Info page switch, off by default, makes the alarm start at 10% volume and step up evenly to full over 30 seconds, for heavy sleepers. It scales only the alarm's own player, never the system alarm volume, and the ramp is cancelled wherever the player is released. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 18 ++++++-- .../dev/susiee/dosekeeper/AlarmService.kt | 43 +++++++++++++++++++ .../dev/susiee/dosekeeper/MainActivity.kt | 8 ++++ .../dev/susiee/dosekeeper/SnoozePrefs.kt | 14 ++++++ lib/services/alarm_bridge.dart | 9 ++++ lib/ui/info_page.dart | 18 ++++++++ 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index bd39435..23af882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -355,6 +355,21 @@ the user's phone. `master` is branch-protected — see "Git workflow" below. to 10 there. The alarm button label ("SNOOZE N MIN") shows the chosen length. Not in the JSON folder backup (dose data only); Google backup carries it like any preference. +- **Gradually louder alarm** (`SnoozePrefs.volumeRamp`, Info page "Gradually louder + alarm") -- opt-in, **default off**: an alarm must stay full volume unless the user + explicitly chooses otherwise. When on, `AlarmService` starts the `MediaPlayer` at 10% + (`setVolume`, which only scales against the alarm stream -- the stream volume itself is + never touched) and a main-looper `Handler` steps it up evenly to full over 30 seconds, + then leaves it at full; when off, ringing behaves exactly as before. Stored in the same + native `dosekeeper_settings` prefs file as the snooze length, for the same reason + (AlarmService rings while Dart may not be running), and likewise not in the JSON folder + backup. Ramp callbacks are cancelled in `releaseRingingResources()`, which covers every + stop/release path including a re-ring that replaces the player. The Android-12 fallback + ring (`notifyInstead`, a notification sound played by the system) can't ramp -- only the + service's own player does. CI-verified only -- **not yet verified on a real device**: + check the ramp is audible from quiet up to full, and that Taken/Snooze or a second + simultaneous dose mid-ramp never crashes or leaves it stuck quiet. + - **Info page** (`lib/ui/info_page.dart`, app-bar info icon) -- version as Android reports it, so it matches App info. Debug builds get a `-debug` suffix (`versionNameSuffix`). The source link hands off to the browser with an intent, so release builds still need no @@ -410,9 +425,6 @@ the user's phone. `master` is branch-protected — see "Git workflow" below. ### Future feature ideas (not prioritized, not committed to) -Reliability-adjacent — fits the core mission, worth doing before pure nice-to-haves: -- **Gradually-increasing alarm volume** for heavy sleepers who sleep through a flat tone. - Quality of life: - **"As-needed" (PRN) medications** — no fixed schedule, just a manual log button for ad hoc doses. Common for real regimens (e.g. pain relief) that the current diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt index f81d8bb..7e96033 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt @@ -12,7 +12,9 @@ import android.media.AudioManager import android.media.MediaPlayer import android.media.RingtoneManager import android.os.Build +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.PowerManager import android.os.VibrationEffect import android.os.Vibrator @@ -32,6 +34,7 @@ class AlarmService : Service() { private var player: MediaPlayer? = null private var vibrator: Vibrator? = null private var wakeLock: PowerManager.WakeLock? = null + private var rampHandler: Handler? = null override fun onBind(intent: Intent?): IBinder? = null @@ -97,7 +100,11 @@ class AlarmService : Service() { ) isLooping = true prepare() + // Quiet before start(), so not even the first moment plays at full volume. + val ramp = SnoozePrefs.volumeRamp(this@AlarmService) + if (ramp) setVolume(RAMP_START_VOLUME, RAMP_START_VOLUME) start() + if (ramp) startVolumeRamp(this) } // Make sure the alarm stream is actually audible. val am = getSystemService(Context.AUDIO_SERVICE) as AudioManager @@ -113,6 +120,35 @@ class AlarmService : Service() { } } + /** + * Opt-in ("Gradually louder alarm" on the Info page): step the volume up evenly from + * RAMP_START_VOLUME to full over 30s, then leave it at full. For heavy sleepers a + * flat blast doesn't wake. setVolume only scales this player against the alarm + * stream's volume; the stream volume itself is never touched. + */ + private fun startVolumeRamp(p: MediaPlayer) { + val handler = Handler(Looper.getMainLooper()).also { rampHandler = it } + var step = 0 + val tick = object : Runnable { + override fun run() { + // releaseRingingResources() cancels these callbacks; the identity check + // covers any post that was already queued, so a released or replaced + // player is never touched. + if (player !== p) return + step++ + if (step >= RAMP_STEPS) { + p.setVolume(1f, 1f) + rampHandler = null + return + } + val v = RAMP_START_VOLUME + (1f - RAMP_START_VOLUME) * step / RAMP_STEPS + p.setVolume(v, v) + handler.postDelayed(this, RAMP_STEP_MS) + } + } + handler.postDelayed(tick, RAMP_STEP_MS) + } + private fun startVibrating() { vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { (getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager).defaultVibrator @@ -136,6 +172,10 @@ class AlarmService : Service() { } private fun releaseRingingResources() { + // Cancel the volume ramp first: its callbacks must never touch the player below + // once it's released (this runs on a re-ring too, before the new player starts). + rampHandler?.removeCallbacksAndMessages(null) + rampHandler = null // stop() throws if the player never started (e.g. prepare() failed). release() // must still run, or the native player leaks for the life of the process. player?.let { p -> @@ -165,6 +205,9 @@ class AlarmService : Service() { private const val FALLBACK_CHANNEL_ID = "dose_alarms_fallback" const val NOTIFICATION_ID = 4242 private const val RING_TIMEOUT_MS = 5 * 60 * 1000L + private const val RAMP_START_VOLUME = 0.1f + private const val RAMP_STEPS = 30 + private const val RAMP_STEP_MS = 1000L const val EXTRA_RING_ON_OPEN = "ring_on_open" private fun createChannel(context: Context) { diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt index 28ec582..2a9a39d 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/MainActivity.kt @@ -222,6 +222,14 @@ class MainActivity : FlutterActivity() { result.success(SnoozePrefs.snoozeMinutes(this)) } + /** The volume-ramp switch; native, so AlarmService can ring without Dart. */ + "getVolumeRamp" -> result.success(SnoozePrefs.volumeRamp(this)) + + "setVolumeRamp" -> { + SnoozePrefs.setVolumeRamp(this, call.argument("enabled") == true) + result.success(SnoozePrefs.volumeRamp(this)) + } + "openUrl" -> { try { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(call.argument("url")))) diff --git a/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt b/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt index a89fdd3..14e0707 100644 --- a/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt +++ b/android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt @@ -14,6 +14,7 @@ import android.content.Context object SnoozePrefs { private const val PREFS = "dosekeeper_settings" private const val KEY_SNOOZE_MINUTES = "snooze_minutes" + private const val KEY_VOLUME_RAMP = "volume_ramp" val ALLOWED = setOf(5, 10, 15, 30) @@ -29,6 +30,19 @@ object SnoozePrefs { prefs(context).edit().putInt(KEY_SNOOZE_MINUTES, minutes).apply() } + /** + * Whether the ring starts quiet and ramps to full volume. Default off: an alarm + * must stay full volume unless the user explicitly chooses otherwise. Read by + * AlarmService, which rings while Dart may not be running -- hence native, like + * the snooze length. + */ + fun volumeRamp(context: Context): Boolean = + prefs(context).getBoolean(KEY_VOLUME_RAMP, false) + + fun setVolumeRamp(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_VOLUME_RAMP, enabled).apply() + } + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) } \ No newline at end of file diff --git a/lib/services/alarm_bridge.dart b/lib/services/alarm_bridge.dart index 1523141..87005b5 100644 --- a/lib/services/alarm_bridge.dart +++ b/lib/services/alarm_bridge.dart @@ -103,6 +103,15 @@ class AlarmBridge { static Future setSnoozeMinutes(int minutes) => _channel.invokeMethod('setSnoozeMinutes', {'minutes': minutes}); + /// Whether the alarm starts quiet and steps up to full volume over 30 seconds. + /// Stored natively because AlarmService rings while Dart may not be running. + /// Default off: an alarm stays full volume unless the user chooses otherwise. + static Future getVolumeRamp() async => + await _channel.invokeMethod('getVolumeRamp') ?? false; + + static Future setVolumeRamp(bool enabled) => + _channel.invokeMethod('setVolumeRamp', {'enabled': enabled}); + /// Opens [url] in the browser. The app itself still makes no network requests. /// False if nothing on the phone can open it. static Future openUrl(String url) async => diff --git a/lib/ui/info_page.dart b/lib/ui/info_page.dart index f663264..30890d3 100644 --- a/lib/ui/info_page.dart +++ b/lib/ui/info_page.dart @@ -23,6 +23,7 @@ class InfoPage extends StatefulWidget { class _InfoPageState extends State { final _version = AlarmBridge.appVersion(); var _snoozeMinutes = AlarmBridge.getSnoozeMinutes(); + var _volumeRamp = AlarmBridge.getVolumeRamp(); var _versionTaps = 0; /// Android makes you a developer after tapping the build number. Here, nobody came. @@ -88,6 +89,23 @@ class _InfoPageState extends State { ), ), ), + // Opt-in, default off: an alarm must stay full volume unless the user + // explicitly chooses otherwise (the app exists because alarms must fire). + FutureBuilder( + future: _volumeRamp, + builder: (_, snapshot) => SwitchListTile( + title: const Text('Gradually louder alarm'), + subtitle: const Text( + 'Starts quiet and reaches full volume over 30 seconds', + ), + value: snapshot.data ?? false, + onChanged: (enabled) async { + await AlarmBridge.setVolumeRamp(enabled); + if (!mounted) return; + setState(() => _volumeRamp = AlarmBridge.getVolumeRamp()); + }, + ), + ), const ListTile(title: Text('License'), subtitle: Text('MIT')), const ListTile( title: Text('Your data'),