001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.app.Notification; 007import android.app.NotificationManager; 008import android.app.PendingIntent; 009import android.app.Service; 010import android.content.Context; 011import android.content.Intent; 012import android.content.pm.ServiceInfo; 013import android.graphics.drawable.Icon; 014import android.media.AudioAttributes; 015import android.media.AudioFocusRequest; 016import android.media.AudioManager; 017import android.media.MediaPlayer; 018import android.media.Ringtone; 019import android.media.RingtoneManager; 020import android.media.ToneGenerator; 021import android.net.Uri; 022import android.os.Build; 023import android.os.Handler; 024import android.os.IBinder; 025import android.os.Looper; 026import android.os.PowerManager; 027import android.os.SystemClock; 028import android.provider.Settings; 029import android.text.format.DateFormat; 030import android.util.Log; 031 032import java.io.IOException; 033import java.util.ArrayList; 034import java.util.Date; 035import java.util.List; 036 037/** 038 * The alarm at waking, ringing (WakeAlarms): a service in the foreground for 039 * as long as it rings, its notification on the "Waking" channel with the 040 * screen over the lock screen (WakeScreen), Stop and 10 min later. Sioul plays 041 * the sound itself, the phone's alarm sound as an alarm (it comes through 042 * Android's do-not-disturb, which lets alarms through unless set to total 043 * silence), rising from very low to full over half a minute: a channel's 044 * sound would be at one volume, and an insistent one stops as the shade 045 * opens. It rings until Stop or 10 min later; never silent: another sound 046 * when the alarm's does not play, beeps when none does, and the ring without 047 * this service when Android refuses it. From Android 14 the service is of 048 * the type alarms keep ringing with ("systemExempted", allowed to apps that 049 * set exact alarms). A try ("Try the alarm") rings here the same way; its 050 * Stop and 10 min later touch only the try: 10 min later rings it once more, 051 * then it offers Stop alone. A waking that comes while a try rings takes it 052 * over; a try that comes while a waking rings changes nothing. 053 */ 054public final class WakeRinger extends Service 055{ 056 /** The ring's notification, by its number alone: the doses' are their keys and 1, the time running's 2. */ 057 static final int NOTE = 3; 058 /** After "10 min later": when it rings again, with Stop; a try's, its own. */ 059 static final int AGAIN_NOTE = 4; 060 static final int TRY_AGAIN_NOTE = 5; 061 /** The sound rises over half a minute, from 40 dB under full, evenly to the ear. */ 062 private static final long RISE_MS = 30_000; 063 private static final double QUIET_DB = -40; 064 065 /** The ring going on in this process: Stop and 10 min later reach it here. */ 066 private static volatile WakeRinger running; 067 /** Rung without this service, Android having refused it: its sound. */ 068 private static Ringtone bare; 069 /** Held from the alarm to the sound playing: the phone does not fall asleep between. */ 070 private static PowerManager.WakeLock held; 071 /** What rings now: a try; a try's last ring (no "10 min later"). */ 072 private static volatile boolean trying; 073 private static volatile boolean last; 074 075 private final Handler handler = new Handler(Looper.getMainLooper()); 076 private MediaPlayer player; 077 private ToneGenerator beeps; 078 private AudioFocusRequest focus; 079 private long started; 080 081 // ---------------------------------------------------------------- from the receiver and the screen 082 083 /** 084 * The alarm rings (WakeReceiver): this service, in the foreground; else 085 * the ring from here. `trial`: a try's; `lastTry`: a try's last ring. 086 */ 087 static void ring(Context context, long at, boolean trial, boolean lastTry) 088 { 089 hold(context); 090 try { 091 context.startForegroundService(new Intent(context, WakeRinger.class).setAction(trial ? WakeAlarms.TRY : WakeAlarms.RING) 092 .putExtra(WakeAlarms.AT, at).putExtra(WakeAlarms.TRIAL, trial).putExtra(WakeAlarms.LAST, lastTry)); 093 } catch (RuntimeException e) { 094 Log.e(DoseAlarms.TAG, "Waking: the ringer could not start; it rings from here.", e); 095 ringBare(context, at, trial, lastTry); 096 } 097 } 098 099 /** 100 * Stop, or 10 min later (its buttons, its notification swiped away, the 101 * screen's buttons). What rings says whether it is a try; when nothing 102 * rings here (a "rings again" line's Stop, Sioul started again since), 103 * the button's own word, `trial`. 104 */ 105 static void stop(Context context, boolean later, boolean trial) 106 { 107 WakeRinger ringer = running; 108 if (ringer != null) 109 ringer.end(later); 110 else 111 ended(context, later, bare != null ? trying : trial, bare != null && last); 112 } 113 114 /** Whether what rings is a try. */ 115 static boolean trying() 116 { 117 return trying; 118 } 119 120 /** Whether "10 min later" is offered: always but on a try's last ring. */ 121 static boolean laterOffered() 122 { 123 return !(trying && last); 124 } 125 126 /** What rings from now: a waking takes a try over; a try never takes a waking over. */ 127 private static synchronized void begin(boolean trial, boolean lastTry, boolean ringingAlready) 128 { 129 if (!trial) { 130 trying = false; 131 last = false; 132 } else if (!ringingAlready || trying) { 133 trying = true; 134 last = lastTry; 135 } 136 } 137 138 /** Whether it rings now (the screen opened late shows nothing). */ 139 static boolean ringing() 140 { 141 return running != null || bare != null; 142 } 143 144 // ---------------------------------------------------------------- the service 145 146 @Override 147 public int onStartCommand(Intent intent, int flags, int id) 148 { 149 long at = intent == null ? System.currentTimeMillis() : intent.getLongExtra(WakeAlarms.AT, System.currentTimeMillis()); 150 begin(intent != null && intent.getBooleanExtra(WakeAlarms.TRIAL, false), intent != null && intent.getBooleanExtra(WakeAlarms.LAST, false), running == this); 151 try { 152 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) 153 startForeground(NOTE, note(this, at), ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED); 154 else 155 startForeground(NOTE, note(this, at)); 156 } catch (RuntimeException e) { 157 Log.e(DoseAlarms.TAG, "Waking: Android refused the ringer in the foreground; it rings without it.", e); 158 ringBare(this, at, trying, last); 159 stopSelf(); 160 return START_NOT_STICKY; 161 } 162 // Its own "rings again" line, said: it rings now. 163 getSystemService(NotificationManager.class).cancel(trying ? TRY_AGAIN_NOTE : AGAIN_NOTE); 164 // The screen already shown (a ring over another): its buttons as they are now. 165 WakeScreen.refresh(); 166 // Rung again while ringing ("10 min later" and the next waking at once): it goes on. 167 if (running != this) { 168 running = this; 169 play(); 170 } 171 // The player keeps the phone awake while it plays; beeps need the hold kept until the end. 172 if (player != null) 173 release(); 174 return START_NOT_STICKY; 175 } 176 177 @Override 178 public void onDestroy() 179 { 180 silence(); 181 if (running == this) 182 running = null; 183 super.onDestroy(); 184 } 185 186 @Override 187 public IBinder onBind(Intent intent) 188 { 189 return null; 190 } 191 192 /** Stop, or 10 min later: silent, the notification and the screen gone. */ 193 private void end(boolean later) 194 { 195 silence(); 196 running = null; 197 stopForeground(STOP_FOREGROUND_REMOVE); 198 stopSelf(); 199 ended(this, later, trying, last); 200 } 201 202 /** 203 * After the ring, whichever way it rang: 10 min later set, or nothing 204 * more until the next waking. A try's touch only the try: 10 min later 205 * rings it once more, as a try, its last time; after that, or at Stop, 206 * it is over. The next waking and its own "10 min later" stay as they are. 207 */ 208 private static synchronized void ended(Context context, boolean later, boolean trial, boolean lastTry) 209 { 210 if (bare != null) { 211 bare.stop(); 212 bare = null; 213 } 214 release(); 215 NotificationManager notifications = context.getSystemService(NotificationManager.class); 216 notifications.cancel(NOTE); 217 if (trial) { 218 if (later && !lastTry) { 219 again(context, WakeAlarms.tryLater(context), true); 220 } else { 221 WakeAlarms.tryStopped(context); 222 notifications.cancel(TRY_AGAIN_NOTE); 223 } 224 } else if (later) { 225 again(context, WakeAlarms.later(context), false); 226 } else { 227 WakeAlarms.stopped(context); 228 notifications.cancel(AGAIN_NOTE); 229 } 230 trying = false; 231 last = false; 232 WakeScreen.close(); 233 } 234 235 // ---------------------------------------------------------------- the sound 236 237 private static AudioAttributes alarm() 238 { 239 return new AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_ALARM).setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build(); 240 } 241 242 /** 243 * The sounds tried in turn: the phone's alarm sound (the settings' own 244 * address, which Android keeps readable before the first unlock), then 245 * the same by its file, the notifications' sound, the ringtone. 246 */ 247 private static List<Uri> sounds(Context context) 248 { 249 List<Uri> sounds = new ArrayList<>(); 250 sounds.add(Settings.System.DEFAULT_ALARM_ALERT_URI); 251 try { 252 Uri chosen = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_ALARM); 253 if (chosen != null) 254 sounds.add(chosen); 255 } catch (RuntimeException e) { 256 // Not readable now (before the first unlock): the others. 257 } 258 sounds.add(Settings.System.DEFAULT_NOTIFICATION_URI); 259 sounds.add(Settings.System.DEFAULT_RINGTONE_URI); 260 return sounds; 261 } 262 263 private void play() 264 { 265 AudioAttributes alarm = alarm(); 266 focus = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT).setAudioAttributes(alarm).build(); 267 getSystemService(AudioManager.class).requestAudioFocus(focus); 268 for (Uri sound : sounds(this)) { 269 MediaPlayer tried = new MediaPlayer(); 270 try { 271 tried.setAudioAttributes(alarm); 272 tried.setWakeMode(this, PowerManager.PARTIAL_WAKE_LOCK); 273 tried.setDataSource(this, sound); 274 tried.setLooping(true); 275 tried.prepare(); 276 tried.setVolume(gain(0), gain(0)); 277 tried.start(); 278 player = tried; 279 break; 280 } catch (IOException | RuntimeException e) { 281 tried.release(); 282 Log.w(DoseAlarms.TAG, "Waking: " + sound + " does not play: " + e); 283 } 284 } 285 started = SystemClock.elapsedRealtime(); 286 if (player != null) 287 handler.post(rise); 288 else 289 beep(); 290 } 291 292 /** The gain at `t` of the rise (0 to 1): even steps in decibels, as the ear hears them. */ 293 private static float gain(double t) 294 { 295 return (float) Math.pow(10, QUIET_DB * (1 - Math.min(1, Math.max(0, t))) / 20); 296 } 297 298 private final Runnable rise = new Runnable() { 299 @Override 300 public void run() 301 { 302 if (player == null) 303 return; 304 double t = (SystemClock.elapsedRealtime() - started) / (double) RISE_MS; 305 try { 306 player.setVolume(gain(t), gain(t)); 307 } catch (IllegalStateException e) { 308 return; 309 } 310 if (t < 1) 311 handler.postDelayed(this, 250); 312 } 313 }; 314 315 /** No sound plays: beeps, as an alarm, at full; the phone kept awake for them until the end. */ 316 private void beep() 317 { 318 try { 319 beeps = new ToneGenerator(AudioManager.STREAM_ALARM, ToneGenerator.MAX_VOLUME); 320 } catch (RuntimeException e) { 321 Log.e(DoseAlarms.TAG, "Waking: no sound at all could play.", e); 322 return; 323 } 324 synchronized (WakeRinger.class) { 325 if (held != null) 326 held.acquire(); 327 } 328 handler.post(beeping); 329 } 330 331 private final Runnable beeping = new Runnable() { 332 @Override 333 public void run() 334 { 335 if (beeps == null) 336 return; 337 beeps.startTone(ToneGenerator.TONE_PROP_BEEP2, 600); 338 handler.postDelayed(this, 1500); 339 } 340 }; 341 342 private void silence() 343 { 344 handler.removeCallbacks(rise); 345 handler.removeCallbacks(beeping); 346 if (player != null) { 347 try { 348 player.stop(); 349 } catch (IllegalStateException e) { 350 // Never started. 351 } 352 player.release(); 353 player = null; 354 } 355 if (beeps != null) { 356 beeps.release(); 357 beeps = null; 358 } 359 if (focus != null) { 360 getSystemService(AudioManager.class).abandonAudioFocusRequest(focus); 361 focus = null; 362 } 363 } 364 365 /** Android refused this service: the notification and the alarm sound from here, as long as Android lets the process be. */ 366 private static synchronized void ringBare(Context context, long at, boolean trial, boolean lastTry) 367 { 368 begin(trial, lastTry, bare != null); 369 context.getSystemService(NotificationManager.class).notify(NOTE, note(context, at)); 370 if (bare != null) 371 return; 372 bare = RingtoneManager.getRingtone(context, Settings.System.DEFAULT_ALARM_ALERT_URI); 373 if (bare == null) 374 bare = RingtoneManager.getRingtone(context, Settings.System.DEFAULT_NOTIFICATION_URI); 375 if (bare != null) { 376 bare.setAudioAttributes(alarm()); 377 bare.setLooping(true); 378 bare.play(); 379 } 380 } 381 382 private static synchronized void hold(Context context) 383 { 384 if (held == null) { 385 held = context.getSystemService(PowerManager.class).newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sioul:waking"); 386 held.setReferenceCounted(false); 387 } 388 held.acquire(60_000); 389 } 390 391 private static synchronized void release() 392 { 393 if (held != null && held.isHeld()) 394 held.release(); 395 } 396 397 // ---------------------------------------------------------------- what it shows 398 399 /** "07:00", as the phone writes times (12 or 24 hours). */ 400 static String clock(Context context, long at) 401 { 402 return DateFormat.getTimeFormat(context).format(new Date(at)); 403 } 404 405 /** 406 * The ring's notification: the screen over the lock screen (full-screen, 407 * WakeScreen), Stop and 10 min later (not on a try's last ring); swiped 408 * away, it stops. Shown on the lock screen whole: a time and two buttons. 409 * A try's buttons by codes of their own, saying a try's. 410 */ 411 private static Notification note(Context context, long at) 412 { 413 boolean trial = trying; 414 int stop = trial ? WakeAlarms.TRY_STOP_CODE : WakeAlarms.STOP_CODE; 415 WakeAlarms.channel(context); 416 PendingIntent screen = PendingIntent.getActivity(context, WakeAlarms.SCREEN_CODE, 417 new Intent(context, WakeScreen.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_USER_ACTION), 418 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 419 Notification.Builder built = new Notification.Builder(context, WakeAlarms.CHANNEL) 420 .setSmallIcon(R.drawable.sioul_notification) 421 .setColor(context.getColor(R.color.sioul_green)) 422 .setContentTitle(WakeAlarms.word(context, "title")) 423 .setContentText(clock(context, at)) 424 .setCategory(Notification.CATEGORY_ALARM) 425 .setVisibility(Notification.VISIBILITY_PUBLIC) 426 .setOngoing(true) 427 .setShowWhen(false) 428 .setFullScreenIntent(screen, true) 429 .setContentIntent(screen) 430 .setDeleteIntent(button(context, WakeAlarms.STOP, stop, trial)) 431 .addAction(action(context, WakeAlarms.STOP, stop, "stop", trial)); 432 if (laterOffered()) 433 built.addAction(action(context, WakeAlarms.LATER, trial ? WakeAlarms.TRY_LATER_CODE : WakeAlarms.LATER_CODE, "later", trial)); 434 return built.build(); 435 } 436 437 /** 438 * After "10 min later": when it rings again, with Stop (a try's, its own 439 * line and Stop). Silent: the "Waking" channel has no sound of its own 440 * ({@link WakeAlarms#channel}). 441 */ 442 private static void again(Context context, long at, boolean trial) 443 { 444 WakeAlarms.channel(context); 445 Notification note = new Notification.Builder(context, WakeAlarms.CHANNEL) 446 .setSmallIcon(R.drawable.sioul_notification) 447 .setColor(context.getColor(R.color.sioul_green)) 448 .setContentTitle(WakeAlarms.word(context, "title")) 449 .setContentText(WakeAlarms.word(context, "again").replace("{time}", clock(context, at))) 450 .setCategory(Notification.CATEGORY_STATUS) 451 .setVisibility(Notification.VISIBILITY_PUBLIC) 452 .setShowWhen(false) 453 .setOnlyAlertOnce(true) 454 .setTimeoutAfter(at - System.currentTimeMillis() + 60_000L) 455 .addAction(action(context, WakeAlarms.STOP, trial ? WakeAlarms.TRY_STOP_CODE : WakeAlarms.STOP_CODE, "stop", trial)) 456 .build(); 457 context.getSystemService(NotificationManager.class).notify(trial ? TRY_AGAIN_NOTE : AGAIN_NOTE, note); 458 } 459 460 private static PendingIntent button(Context context, String what, int code, boolean trial) 461 { 462 return PendingIntent.getBroadcast(context, code, new Intent(context, WakeReceiver.class).setAction(what).putExtra(WakeAlarms.TRIAL, trial), 463 PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); 464 } 465 466 private static Notification.Action action(Context context, String what, int code, String word, boolean trial) 467 { 468 return new Notification.Action.Builder(Icon.createWithResource(context, R.drawable.sioul_notification), WakeAlarms.word(context, word), button(context, what, code, trial)).build(); 469 } 470}