001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.app.ActivityManager;
007import android.app.AlarmManager;
008import android.app.Notification;
009import android.app.NotificationManager;
010import android.app.PendingIntent;
011import android.app.Service;
012import android.content.ActivityNotFoundException;
013import android.content.Context;
014import android.content.Intent;
015import android.content.SharedPreferences;
016import android.content.pm.ServiceInfo;
017import android.net.Uri;
018import android.os.Build;
019import android.os.FileObserver;
020import android.os.Handler;
021import android.os.HandlerThread;
022import android.os.IBinder;
023import android.os.PowerManager;
024import android.os.Process;
025import android.os.SystemClock;
026import android.provider.Settings;
027import android.util.Log;
028
029import org.json.JSONException;
030import org.json.JSONObject;
031
032import java.util.List;
033import java.util.Locale;
034
035/**
036 * Sioul in the background: it keeps your devices in step while Sioul is
037 * closed (crates/sioul-app/src/steps.rs; docs/android.md, "In the
038 * background"). A foreground service in a process of its own (":steps"):
039 * Qt's activity ends its whole process when it closes (System.exit in
040 * QtActivityBase.onDestroy), which would end a service living there too. It
041 * holds Sioul's library without Qt's window, and one quiet notification.
042 *
043 * - An alarm through Doze (exact while allowed, else inexact) asks Rust for
044 *   a step: the other devices' news read (Sioul's own pull from the server
045 *   while it works, else the sync app asked to look, then a quick exchange),
046 *   do-not-disturb asked of Sioul's own process when it changed
047 *   (DndReceiver), mail at its rhythm; Rust says when to look next.
048 * - A watch on the sharing folder: another device's file written by the
049 *   sync app brings a step three seconds later, without waiting for the alarm
050 *   (Rust leaves it out when its pull brought that file already).
051 * - The phone is kept awake only while a step runs: a step asked for later
052 *   (a folder's, a message's) comes by an exact alarm of its own (`later`).
053 * - While Sioul's window is on the screen, it exchanges by itself: the steps
054 *   wait.
055 * - While this phone screens calls (Calls.java), its notification offers "Let
056 *   every call through", for an hour or until turned off, and "Screen calls
057 *   again"; each step keeps the calls' table in step too.
058 *
059 * Rust's questions come through call(verb, json) (android/main.cpp), also
060 * from Sioul's own process: starting and stopping the service, the stars of
061 * the list's people (DndContacts), the battery's exemption, the alarm of
062 * do-not-disturb's next end (DndReceiver).
063 */
064public final class StepService extends Service
065{
066    static final String TAG = DoseAlarms.TAG;
067    static final String STEP = "com.aurelienpierre.sioul.action.STEP";
068    /** "Let every call through" pressed on the notification (StepReceiver): extras "on", "minutes". */
069    static final String CALLS = "com.aurelienpierre.sioul.action.CALLS";
070    /** A press of the switch heard in Sioul's own process (DndReceiver, the tile): sent at once (StepReceiver). */
071    static final String HEARD = "com.aurelienpierre.sioul.action.HEARD";
072    /** The notification's words, as Rust says them after each apply in Sioul's own process (StepReceiver): extra "words". */
073    static final String NOTE_WORDS = "com.aurelienpierre.sioul.action.NOTE_WORDS";
074    /** A message's line written for your computers by the listener (AppNotes, `soon`): a step soon shares it. */
075    static final String SOON = "com.aurelienpierre.sioul.action.SOON";
076    /** The alarm of a step asked for later (`later`): a folder's, a message's. */
077    static final String LATER = "com.aurelienpierre.sioul.action.LATER";
078    /** How long a step asked by `SOON` waits: the messages that follow go with it. */
079    private static final long SOON_MS = 20_000;
080    private static final String CHANNEL = "steps";
081    private static final int NOTE = 0x5137;
082    private static final String KEPT = "sioul-steps";
083    /** Seconds between two steps when Rust gave no answer. */
084    private static final long USUAL_S = 300;
085    /** A step holds the phone awake this long at most. */
086    private static final long AWAKE_MS = 90_000;
087    /** The sync app's writing settles this long before a step reads it. */
088    private static final long SETTLE_MS = 3_000;
089
090    private static volatile StepService running;
091
092    private HandlerThread thread;
093    private Handler worker;
094    private PowerManager.WakeLock awake;
095    private FileObserver watch;
096    private String watched = "";
097    private String own = "";
098    /** Its text: do-not-disturb on or off, the calls screened or ringing (Rust's `steps::note`). */
099    private String line = "";
100    /** Its title: what now is for, until when; Sioul's words for the service before Rust gave any. */
101    private String title = "";
102    private String channelName = "";
103    /**
104     * The calls screened, as Rust words them at each step: {screening, through,
105     * line, hour, off, again} (crates/sioul-app/src/calls.rs, `step`);
106     * empty while this phone does not screen calls.
107     */
108    private String calls = "";
109    /** A step asked while one runs: the phone stays awake for it. */
110    private volatile boolean posted;
111    /** The step asked for later, waiting for its alarm (`later`); none: null. */
112    private Later waiting;
113    /** The names of the other devices' files the sync app wrote since the last step, for Rust ("folder:"). */
114    private final java.util.Set<String> written = new java.util.TreeSet<>();
115    /** When the words shown were made (Unix milliseconds, Rust's clock): older ones are left aside. */
116    private long saidAt;
117
118    /** One step, on the worker thread: {next (seconds), folder, own, line, title, channel, stop}. */
119    static native String nativeStep(String reason);
120
121    /** Tells Rust it runs in the service's process: it never touches Android's modes from here. */
122    static native void nativeService();
123
124    // ---------------------------------------------------------------- Rust's questions
125
126    /** Rust's question (android/main.cpp), from either process. Any thread. */
127    static String call(Context context, String verb, String json)
128    {
129        try {
130            JSONObject asked = json == null || json.isEmpty() ? new JSONObject() : new JSONObject(json);
131            switch (verb) {
132            case "start":
133                return start(context, asked) ? "true" : "false";
134            case "stop":
135                stop(context);
136                return "true";
137            case "running":
138                return runningHere(context) ? "true" : "false";
139            case "battery":
140                return exempt(context) ? "true" : "false";
141            case "ask-battery":
142                askBattery(context);
143                return "true";
144            case "poke":
145                DndReceiver.poke(context);
146                return "true";
147            case "heard":
148                // A press made in Sioul's own process: sent at once by the service when it runs; else Rust sends it.
149                if (!runningHere(context))
150                    return "{\"stepped\":false}";
151                context.sendBroadcast(new Intent(context, StepReceiver.class).setAction(HEARD));
152                return "{\"stepped\":true}";
153            case "next":
154                DndReceiver.schedule(context, asked.optLong("at", 0));
155                return "true";
156            case "note":
157                // The notification's words now, from Sioul's own process: to the service, in its own.
158                return note(context, json) ? "true" : "false";
159            case "stars":
160                return DndContacts.stars(context, asked).toString();
161            case "open-contact":
162                return DndContacts.open(context, asked.optString("uri", "")) ? "true" : "false";
163            case "add-contact":
164                return DndContacts.add(context, asked) ? "true" : "false";
165            case "contacts-allowed":
166                return DndContacts.allowed(context) ? "true" : "false";
167            case "sms-app":
168                // The phone's SMS app, for Settings ▸ This phone ▸ On your computers.
169                return JSONObject.quote(PhoneMessages.smsApp(context));
170            default:
171                // texts: read and sent for your computers (Texts.java, TextSend.java).
172                if (verb.startsWith("texts-"))
173                    return Texts.call(context, verb, asked);
174                // A mail attachment opened in the app you choose, or saved where you choose (Attachments.java).
175                if (verb.startsWith("attachment-"))
176                    return Attachments.call(context, verb, asked);
177                // The calls screened (Calls.java): the role, its pages, the phone app's dial pad.
178                return verb.startsWith("calls-") ? Calls.call(context, verb, asked) : null;
179            }
180        } catch (JSONException | RuntimeException e) {
181            Log.e(TAG, "Steps: " + verb + " failed: " + e);
182            return null;
183        }
184    }
185
186    /**
187     * Whether it runs and its words, written by Sioul's own process and read
188     * by the service's: read again from the file when it changed (both
189     * processes use it, rarely).
190     */
191    @SuppressWarnings("deprecation")
192    private static SharedPreferences kept(Context context)
193    {
194        return context.getSharedPreferences(KEPT, Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS);
195    }
196
197    /**
198     * The service started, or told new words: {title, channel}. Kept, so that
199     * a restart of the phone starts it again with them. False when Android
200     * refused (Sioul in the background on Android 12 and later).
201     */
202    static boolean start(Context context, JSONObject words)
203    {
204        kept(context).edit().putBoolean("on", true)
205            .putString("title", words.optString("title", ""))
206            .putString("channel", words.optString("channel", "")).commit();
207        // Running already: nothing more (its next step brings the words).
208        if (runningHere(context))
209            return true;
210        Intent intent = new Intent(context, StepService.class).setAction(STEP).putExtra("reason", "start")
211            .putExtra("title", words.optString("title", "")).putExtra("channel", words.optString("channel", ""));
212        try {
213            context.startForegroundService(intent);
214            return true;
215        } catch (IllegalStateException | SecurityException e) {
216            // Not allowed from the background now: the next start of Sioul's window, or the next restart.
217            Log.w(TAG, "Steps: not started now: " + e);
218            return false;
219        }
220    }
221
222    /** Stopped, and not started again at the phone's restart. */
223    static void stop(Context context)
224    {
225        kept(context).edit().putBoolean("on", false).commit();
226        context.stopService(new Intent(context, StepService.class));
227        cancel(context);
228    }
229
230    /** At the phone's restart or after an update (StepReceiver): started again if it was on. */
231    static void restart(Context context, String reason)
232    {
233        if (!kept(context).getBoolean("on", false))
234            return;
235        try {
236            context.startForegroundService(new Intent(context, StepService.class).setAction(STEP).putExtra("reason", reason));
237        } catch (IllegalStateException | SecurityException e) {
238            Log.w(TAG, "Steps: not started again: " + e);
239        }
240    }
241
242    /** An alarm's step (StepReceiver): to the service running, else it starts (an exact alarm allows it). */
243    static void alarm(Context context)
244    {
245        StepService service = running;
246        if (service != null) {
247            service.step("alarm", 0);
248            return;
249        }
250        restart(context, "alarm");
251    }
252
253    /** Whether the service runs in this very process (its own, ":steps"). */
254    static boolean inThisProcess()
255    {
256        return running != null;
257    }
258
259    /** Whether the service runs, asked from either process. */
260    static boolean runningHere(Context context)
261    {
262        if (running != null)
263            return true;
264        ActivityManager manager = context.getSystemService(ActivityManager.class);
265        try {
266            @SuppressWarnings("deprecation")
267            List<ActivityManager.RunningServiceInfo> services = manager.getRunningServices(64);
268            for (ActivityManager.RunningServiceInfo info : services)
269                if (StepService.class.getName().equals(info.service.getClassName()) && info.foreground)
270                    return true;
271        } catch (SecurityException e) {
272            // Not ours to ask: not known.
273        }
274        return false;
275    }
276
277    /** Whether Android lets Sioul run in the background, battery optimisation aside. */
278    static boolean exempt(Context context)
279    {
280        PowerManager power = context.getSystemService(PowerManager.class);
281        return power != null && power.isIgnoringBatteryOptimizations(context.getPackageName());
282    }
283
284    /** Android's question to let Sioul run in the background; else its list. */
285    static void askBattery(Context context)
286    {
287        Intent[] pages = {
288            new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.getPackageName())),
289            new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS),
290            new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + context.getPackageName())),
291        };
292        for (Intent page : pages) {
293            try {
294                context.startActivity(page.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
295                return;
296            } catch (ActivityNotFoundException | SecurityException e) {
297                // The next one.
298            }
299        }
300    }
301
302    // ---------------------------------------------------------------- the service
303
304    @Override
305    public void onCreate()
306    {
307        super.onCreate();
308        running = this;
309        thread = new HandlerThread("sioul-steps", Process.THREAD_PRIORITY_BACKGROUND);
310        thread.start();
311        worker = new Handler(thread.getLooper());
312        PowerManager power = getSystemService(PowerManager.class);
313        awake = power.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "sioul:steps");
314        awake.setReferenceCounted(false);
315        SharedPreferences words = kept(this);
316        title = words.getString("title", "");
317        channelName = words.getString("channel", "");
318        foreground();
319        // The phone's texts watched in this process: the import asks Android only when they changed.
320        Texts.watch(this);
321    }
322
323    @Override
324    public int onStartCommand(Intent intent, int flags, int startId)
325    {
326        if (intent != null) {
327            String given = intent.getStringExtra("title");
328            if (given != null && !given.isEmpty())
329                title = given;
330            String channel = intent.getStringExtra("channel");
331            if (channel != null && !channel.isEmpty())
332                channelName = channel;
333        }
334        // Within five seconds of a start, every time: Android asks it of a foreground service.
335        foreground();
336        step(intent == null ? "restart" : intent.getStringExtra("reason") == null ? "start" : intent.getStringExtra("reason"), 0);
337        return START_STICKY;
338    }
339
340    @Override
341    public void onDestroy()
342    {
343        running = null;
344        if (watch != null)
345            watch.stopWatching();
346        Texts.unwatch(this);
347        cancelLater(this);
348        if (awake.isHeld())
349            awake.release();
350        thread.quitSafely();
351        super.onDestroy();
352    }
353
354    @Override
355    public IBinder onBind(Intent intent)
356    {
357        return null;
358    }
359
360    /** The service in the foreground with its notification: at each start, as Android asks. */
361    private void foreground()
362    {
363        Notification built = note();
364        try {
365            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
366                startForeground(NOTE, built, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
367            else
368                startForeground(NOTE, built);
369        } catch (RuntimeException e) {
370            // Refused (Android 12 and later, from the background): the next start.
371            Log.w(TAG, "Steps: not in the foreground: " + e);
372            stopSelf();
373        }
374    }
375
376    /** Its notification changed in place: new words, the do-not-disturb line. */
377    private void refresh()
378    {
379        getSystemService(NotificationManager.class).notify(NOTE, note());
380    }
381
382    /** One quiet notification: the lowest importance, no sound, no badge, hidden on the lock screen. */
383    private Notification note()
384    {
385        NotificationManager notifications = getSystemService(NotificationManager.class);
386        notifications.createNotificationChannel(Channels.quiet(CHANNEL, channelName.isEmpty() ? word(0) : channelName, NotificationManager.IMPORTANCE_MIN));
387        Notification.Builder note = new Notification.Builder(this, CHANNEL)
388            .setSmallIcon(R.drawable.sioul_notification)
389            .setColor(getColor(R.color.sioul_green))
390            .setContentTitle(title.isEmpty() ? word(1) : title)
391            .setCategory(Notification.CATEGORY_SERVICE)
392            .setVisibility(Notification.VISIBILITY_SECRET)
393            .setOngoing(true)
394            .setOnlyAlertOnce(true)
395            .setShowWhen(false)
396            .setContentIntent(open(this));
397        // The calls' line while every call rings ("Every call rings until 15:30."), after do-not-disturb's.
398        JSONObject words = callsWords();
399        String through = words == null ? "" : words.optString("line", "");
400        String text = line.isEmpty() ? through : through.isEmpty() ? line : line + " " + through;
401        if (!text.isEmpty()) {
402            // Whole when opened: what now is for above, do-not-disturb and the calls below.
403            note.setContentText(text);
404            note.setStyle(new Notification.BigTextStyle().bigText(text));
405        }
406        // "Let every call through" while this phone screens calls: for an hour or until
407        // turned off; once on, "Screen calls again" (docs/android.md, "Calls").
408        if (words != null && words.optBoolean("screening")) {
409            if (words.optBoolean("through")) {
410                note.addAction(action(this, 0x5142, words.optString("again", ""), false, 0));
411            } else {
412                note.addAction(action(this, 0x5140, words.optString("hour", ""), true, 60));
413                note.addAction(action(this, 0x5141, words.optString("off", ""), true, 0));
414            }
415        }
416        return note.build();
417    }
418
419    private JSONObject callsWords()
420    {
421        if (calls.isEmpty())
422            return null;
423        try {
424            return new JSONObject(calls);
425        } catch (JSONException e) {
426            return null;
427        }
428    }
429
430    /** One of the notification's buttons for the calls: on for `minutes` (0: until turned off), or off. */
431    private static Notification.Action action(Context context, int code, String title, boolean on, int minutes)
432    {
433        Intent press = new Intent(context, StepReceiver.class).setAction(CALLS).putExtra("on", on).putExtra("minutes", minutes);
434        PendingIntent pending = PendingIntent.getBroadcast(context, code, press, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
435        return new Notification.Action.Builder(null, title, pending).build();
436    }
437
438    /**
439     * The notification's words, {title, text, calls}, told by Rust after each
440     * apply in Sioul's own process: to the service running in this process,
441     * else to its process (StepReceiver), only while it runs. True when told.
442     */
443    static boolean note(Context context, String json)
444    {
445        StepService service = running;
446        if (service != null) {
447            service.words(json);
448            return true;
449        }
450        if (!runningHere(context))
451            return false;
452        // Android's queue of foreground broadcasts: never behind one held open meanwhile
453        // (DndReceiver holds its own while Sioul's change settles, three seconds).
454        context.sendBroadcast(new Intent(context, StepReceiver.class).setAction(NOTE_WORDS).putExtra("words", json)
455                                  .addFlags(Intent.FLAG_RECEIVER_FOREGROUND));
456        return true;
457    }
458
459    /** The words told (StepReceiver, in this process): the notification changed in place at once when they differ. */
460    static void told(String json)
461    {
462        StepService service = running;
463        if (service != null && json != null)
464            service.words(json);
465    }
466
467    /**
468     * New words, kept and shown at once when they changed: on the caller's
469     * thread, never behind a step, which may wait for the sync app or for
470     * mail on the worker's.
471     */
472    private void words(String json)
473    {
474        try {
475            said(new JSONObject(json));
476        } catch (JSONException e) {
477            Log.w(TAG, "Steps: words not read: " + e);
478        }
479    }
480
481    /**
482     * The notification's words from Rust ({at, title, text, channel, calls}):
483     * shown again when one changed; words made before those shown (`at`,
484     * Unix milliseconds: a step's, finished after a press told newer ones)
485     * are left aside.
486     */
487    private synchronized void said(JSONObject answer)
488    {
489        long at = answer.optLong("at", 0);
490        if (at > 0 && at < saidAt)
491            return;
492        saidAt = Math.max(saidAt, at);
493        String said = answer.optString("text", answer.optString("line", line));
494        String words = answer.optString("title", title);
495        String channel = answer.optString("channel", channelName);
496        JSONObject callsSaid = answer.optJSONObject("calls");
497        String callsNow = callsSaid == null ? calls : callsSaid.toString();
498        if (!said.equals(line) || !words.equals(title) || !channel.equals(channelName) || !callsNow.equals(calls)) {
499            line = said;
500            title = words;
501            channelName = channel;
502            calls = callsNow;
503            refresh();
504            // When, for the time each change takes to show (adb logcat -s sioul); nothing of what it says.
505            Log.i(TAG, "Steps: the notification says the state now.");
506        }
507    }
508
509    /**
510     * A line written for your computers, from the listener's process: a step
511     * soon, while the service runs (else the window's own exchanges, or the
512     * next start, send it).
513     */
514    static void soon(Context context)
515    {
516        if (runningHere(context))
517            context.sendBroadcast(new Intent(context, StepReceiver.class).setAction(SOON));
518    }
519
520    /**
521     * `SOON` heard (StepReceiver, in this process): a step `SOON_MS` on, or
522     * the one waiting for its alarm then, which shares the line too
523     * (`Later.of`); none when a step is about to run.
524     */
525    static void soonHere()
526    {
527        StepService service = running;
528        if (service != null && !service.posted)
529            service.step("messages", SOON_MS);
530    }
531
532    /** The alarm of the step asked for later (StepReceiver, in this process): it runs now; the service started again if Android had stopped it. */
533    static void laterHere(Context context)
534    {
535        StepService service = running;
536        if (service == null) {
537            restart(context, "alarm");
538            return;
539        }
540        String reason;
541        synchronized (service) {
542            reason = service.waiting == null ? "folder" : service.waiting.reason;
543            service.waiting = null;
544        }
545        service.step(reason, 0);
546    }
547
548    /** A press of the switch made in Sioul's own process (StepReceiver): a step at once, which sends it. */
549    static void heard(Context context)
550    {
551        StepService service = running;
552        if (service != null)
553            service.step("heard", 0);
554    }
555
556    /**
557     * Expecting a call (once "Let every call through") pressed on the
558     * notification (StepReceiver, in this process): kept for the screening at
559     * once ({@link Calls#press}), then a
560     * step, in which Rust shares it with your other devices and words the
561     * notification again.
562     */
563    static void callsPressed(Context context, boolean on, int minutes)
564    {
565        Calls.press(context, on, minutes);
566        StepService service = running;
567        if (service != null)
568            service.step("calls", 0);
569    }
570
571    /** A tap: Sioul, as by its icon. */
572    private static PendingIntent open(Context context)
573    {
574        Intent sioul = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName());
575        if (sioul == null)
576            return null;
577        return PendingIntent.getActivity(context, NOTE, sioul.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED),
578                                         PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
579    }
580
581    /**
582     * A step: now, on the worker thread, the phone kept awake while it waits
583     * there and runs (one asked meanwhile replaces one not started; the one
584     * waiting for its alarm goes, this one does its work); or `delay`
585     * milliseconds on, by an exact alarm of its own, the phone left to sleep
586     * meanwhile (`later`).
587     */
588    void step(String reason, long delay)
589    {
590        if (delay > 0) {
591            later(reason, delay);
592            return;
593        }
594        synchronized (this) {
595            waiting = null;
596        }
597        cancelLater(this);
598        awake.acquire(AWAKE_MS);
599        posted = true;
600        worker.removeCallbacksAndMessages(null);
601        worker.post(() -> run(reason));
602    }
603
604    /**
605     * A step asked for later, as `Later.of` joins it to the one waiting:
606     * its exact alarm set, or moved (one alarm at a time). Through Doze too
607     * (allow-while-idle); Android brings an alarm five seconds on at the
608     * soonest.
609     */
610    private void later(String reason, long delay)
611    {
612        long now = SystemClock.elapsedRealtime();
613        Later next;
614        synchronized (this) {
615            next = Later.of(waiting, reason, delay, now);
616            waiting = next;
617        }
618        AlarmManager alarms = getSystemService(AlarmManager.class);
619        PendingIntent pending = laterPending(this);
620        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms())
621            alarms.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, next.at, pending);
622        else
623            alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, next.at, pending);
624    }
625
626    /**
627     * A step asked for later: its reason, and when it comes (elapsed realtime,
628     * milliseconds). Pure: android/jvm-checks/StepsCheck.java.
629     */
630    static final class Later
631    {
632        final String reason;
633        final long at;
634
635        Later(String reason, long at)
636        {
637            this.reason = reason;
638            this.at = at;
639        }
640
641        /**
642         * `asked`, `delay` ms on at `now`, joined to the one `waiting` (null:
643         * none). A folder's step comes `delay` after the last file written
644         * (each moves it on), never in place of a message's, which then comes
645         * at that time instead: a folder's may find nothing new and be left
646         * out, a message's line must go. A message's joins the one waiting,
647         * at its time (the lines that follow go with it), shares the line
648         * then; none waiting, it comes `delay` on.
649         */
650        static Later of(Later waiting, String asked, long delay, long now)
651        {
652            if (waiting == null)
653                return new Later(asked, now + delay);
654            if ("folder".equals(asked))
655                return new Later(waiting.reason, now + delay);
656            if ("messages".equals(asked))
657                return new Later(asked, waiting.at);
658            return new Later(asked, now + delay);
659        }
660    }
661
662    private void run(String reason)
663    {
664        posted = false;
665        String names = takeWritten();
666        try {
667            // A folder's step whose files a step since has read (their names taken): nothing left to do.
668            if ("folder".equals(reason) && names.isEmpty())
669                return;
670            // Sioul's window on the screen exchanges by itself: the step waits for the next alarm.
671            if (windowShown()) {
672                schedule(this, USUAL_S);
673                return;
674            }
675            DoseAlarms.load(getApplicationContext());
676            nativeService();
677            Texts.watch(getApplicationContext());
678            // A folder's step names the files written, for Rust to leave out one its pull brought already.
679            JSONObject answer = new JSONObject(nativeStep("folder".equals(reason) ? "folder:" + names : reason));
680            // Nothing new: the alarm stays as it was.
681            if (answer.optBoolean("skip"))
682                return;
683            if (answer.optBoolean("stop")) {
684                // Sharing off, or the setting: no more steps; whether it starts again
685                // at the next restart stays as Sioul's window last said (`stop`, `start`).
686                cancel(this);
687                stopSelf();
688                return;
689            }
690            schedule(this, answer.optLong("next", USUAL_S));
691            own = answer.optString("own", own);
692            watchFolder(answer.optString("folder", ""));
693            if (answer.optJSONObject("calls") == null)
694                answer.put("calls", new JSONObject());
695            said(answer);
696        } catch (Throwable e) {
697            // Sioul's library that does not load, a step that failed: tried again at the usual pace.
698            Log.e(TAG, "Steps: a step failed (" + reason + ")", e);
699            schedule(this, USUAL_S);
700        } finally {
701            if (!posted && awake.isHeld())
702                awake.release();
703        }
704    }
705
706    /** Whether Sioul's window is on the screen (its own process in the foreground). */
707    private boolean windowShown()
708    {
709        ActivityManager manager = getSystemService(ActivityManager.class);
710        List<ActivityManager.RunningAppProcessInfo> processes = manager.getRunningAppProcesses();
711        if (processes == null)
712            return false;
713        for (ActivityManager.RunningAppProcessInfo process : processes)
714            if (getPackageName().equals(process.processName) && process.importance <= ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND)
715                return true;
716        return false;
717    }
718
719    /** The sharing folder watched: another device's file written there brings a step. */
720    private void watchFolder(String folder)
721    {
722        if (folder.equals(watched))
723            return;
724        if (watch != null)
725            watch.stopWatching();
726        watch = null;
727        watched = folder;
728        if (folder.isEmpty())
729            return;
730        @SuppressWarnings("deprecation")
731        FileObserver observer = new FileObserver(folder, FileObserver.CLOSE_WRITE | FileObserver.MOVED_TO) {
732            @Override
733            public void onEvent(int event, String name)
734            {
735                if (!othersFile(name, own))
736                    return;
737                synchronized (written) {
738                    written.add(name);
739                }
740                step("folder", SETTLE_MS);
741            }
742        };
743        watch = observer;
744        watch.startWatching();
745    }
746
747    /**
748     * Another device's file, by its name in the sharing folder's top: hidden
749     * names are files being written; this device's own are its own steps.
750     * Pure: android/jvm-checks/StepsCheck.java.
751     */
752    static boolean othersFile(String name, String own)
753    {
754        return name != null && !name.isEmpty() && !name.startsWith(".") && name.indexOf('/') < 0 && (own.isEmpty() || !name.startsWith(own));
755    }
756
757    /** The names written since the last step, "/" between them, forgotten here (any step reads the folder). */
758    private String takeWritten()
759    {
760        synchronized (written) {
761            String names = String.join("/", written);
762            written.clear();
763            return names;
764        }
765    }
766
767    /** The next step's alarm, `seconds` on: exact through Doze while allowed, else inexact. */
768    static void schedule(Context context, long seconds)
769    {
770        AlarmManager alarms = context.getSystemService(AlarmManager.class);
771        long at = SystemClock.elapsedRealtime() + Math.max(60, seconds) * 1000L;
772        PendingIntent step = pending(context);
773        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S || alarms.canScheduleExactAlarms())
774            alarms.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, step);
775        else
776            alarms.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, at, step);
777    }
778
779    private static void cancel(Context context)
780    {
781        context.getSystemService(AlarmManager.class).cancel(pending(context));
782        cancelLater(context);
783    }
784
785    /** The alarm of a step asked for later, taken away: a step now does its work. */
786    private static void cancelLater(Context context)
787    {
788        context.getSystemService(AlarmManager.class).cancel(laterPending(context));
789    }
790
791    private static PendingIntent laterPending(Context context)
792    {
793        return PendingIntent.getBroadcast(context, NOTE + 1, new Intent(context, StepReceiver.class).setAction(LATER),
794                                          PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
795    }
796
797    private static PendingIntent pending(Context context)
798    {
799        return PendingIntent.getBroadcast(context, NOTE, new Intent(context, StepReceiver.class).setAction(STEP),
800                                          PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
801    }
802
803    // ---------------------------------------------------------------- words before Rust gave them
804
805    private static final String[][] WORDS = {
806        { "Devices in step", "Appareils à jour" },
807        { "Sioul keeps your devices in step", "Sioul tient vos appareils à jour" },
808    };
809
810    private static String word(int which)
811    {
812        boolean french = "fr".equals(Locale.getDefault().getLanguage());
813        return WORDS[which][french ? 1 : 0];
814    }
815}