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}