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'),