Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
43 changes: 43 additions & 0 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/AlarmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 ->
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean>("enabled") == true)
result.success(SnoozePrefs.volumeRamp(this))
}

"openUrl" -> {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(call.argument<String>("url"))))
Expand Down
14 changes: 14 additions & 0 deletions android/app/src/main/kotlin/dev/susiee/dosekeeper/SnoozePrefs.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
}
9 changes: 9 additions & 0 deletions lib/services/alarm_bridge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ class AlarmBridge {
static Future<void> 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<bool> getVolumeRamp() async =>
await _channel.invokeMethod<bool>('getVolumeRamp') ?? false;

static Future<void> 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<bool> openUrl(String url) async =>
Expand Down
18 changes: 18 additions & 0 deletions lib/ui/info_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class InfoPage extends StatefulWidget {
class _InfoPageState extends State<InfoPage> {
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.
Expand Down Expand Up @@ -88,6 +89,23 @@ class _InfoPageState extends State<InfoPage> {
),
),
),
// Opt-in, default off: an alarm must stay full volume unless the user
// explicitly chooses otherwise (the app exists because alarms must fire).
FutureBuilder<bool>(
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'),
Expand Down
Loading