001// SPDX-License-Identifier: GPL-3.0-or-later
002// Copyright © 2026 Aurélien Pierre
003
004package com.aurelienpierre.sioul;
005
006import android.Manifest;
007import android.app.KeyguardManager;
008import android.app.Notification;
009import android.app.NotificationChannel;
010import android.app.NotificationManager;
011import android.app.PendingIntent;
012import android.app.Person;
013import android.content.ActivityNotFoundException;
014import android.content.ComponentName;
015import android.content.ContentResolver;
016import android.content.Context;
017import android.content.Intent;
018import android.content.SharedPreferences;
019import android.content.pm.ApplicationInfo;
020import android.content.pm.PackageManager;
021import android.content.pm.ShortcutInfo;
022import android.content.res.Resources;
023import android.database.Cursor;
024import android.net.Uri;
025import android.os.Build;
026import android.os.Bundle;
027import android.os.FileObserver;
028import android.os.Handler;
029import android.os.HandlerThread;
030import android.os.Parcelable;
031import android.os.Process;
032import android.provider.ContactsContract;
033import android.provider.Settings;
034import android.provider.Telephony;
035import android.service.notification.NotificationListenerService;
036import android.service.notification.StatusBarNotification;
037import android.util.Log;
038
039import org.json.JSONArray;
040import org.json.JSONException;
041import org.json.JSONObject;
042
043import java.io.File;
044import java.nio.charset.StandardCharsets;
045import java.util.ArrayList;
046import java.util.HashMap;
047import java.util.List;
048import java.util.Locale;
049import java.util.Map;
050
051/**
052 * Other apps' notifications held until their time (docs/android.md,
053 * "Notifications from other apps"; crates/sioul-app/src/appnotes.rs, which
054 * decides; crates/sioul-core/src/appnotes.rs, how).
055 *
056 * Android's notification listener, bound by the system once the person gives
057 * Sioul "Notification access", in a process of its own (":listener"): Qt's
058 * window ends its whole process as it closes (System.exit in
059 * QtActivityBase.onDestroy), which would unbind the listener at each close,
060 * and the background service's process (":steps") runs only when its own
061 * setting is on and does long work a notification must not wait for. Here,
062 * Sioul's library without Qt's window ({@link DoseAlarms#load}), as for the doses.
063 *
064 * For each notification of another app: what Android says of it, its words
065 * included, handed to Rust in memory on a thread of its own; Rust answers how
066 * long it waits, and Android holds it that long (snoozeNotification): it
067 * comes back whole, with its own tap and actions, alerting as its channel
068 * says. Nothing is ever cancelled, kept or logged; of a conversation, only
069 * where its page is in Android (its app, shortcut and channel, a week), for
070 * the Other apps tab to open it. Nothing is answered or marked as read but
071 * what you ask from a computer, once, through the notification's own Reply
072 * or Mark as read ({@link #answer}).
073 * Never asked about: Sioul's own, ongoing ones (a call, music, a download),
074 * those that cannot be cleared.
075 *
076 * What is held is worked out again (`review`) when something changes that
077 * moves its time: a pause ends, the switch of do-not-disturb, the hours, the
078 * choices. Sioul's files are watched for that.
079 *
080 * Replies from your computers (Replies.java; crates/sioul-app/src/replies.rs):
081 * what each relayed notification offers (a Reply, a Mark as read) is said to
082 * Rust with it, and its going when Android takes it away; what the
083 * background step hands over in Sioul's private replies folder is sent here,
084 * the listener alone holding the notifications, each once ({@link #answer}).
085 */
086public final class AppNotes extends NotificationListenerService
087{
088    /** The log's tag, the doses' own: `adb logcat -s sioul` shows both. */
089    static final String TAG = DoseAlarms.TAG;
090    /** Android's words in a notification it redacted for an untrusted listener (Android 15). */
091    private static final String REDACTED = "redacted_notification_message";
092    /** A sound or vibration this recent was the notification's own, as it came. */
093    private static final long ALERTED_MS = 15_000;
094    /** Changes to Sioul's files settle this long before what is held is worked out again. */
095    private static final long SETTLE_MS = 2_000;
096    /** The files whose change may move what is held: the pause and free time, the
097     *  switch of do-not-disturb and its people, a focus session, the hours and
098     *  settings (who may reach you when among them), the lists of senders, the
099     *  choices, Health's meals and nights, today's slots of time for you (which
100     *  the window writes as it lays out the day). */
101    private static final String[] WATCHED = {
102        "quiet.toml", "do-not-disturb.toml", "dnd-people.toml", "running.toml", "config.toml", "app-notes.toml", "health.toml", "health-days.toml",
103        "safe-senders.txt", "neutral-senders.txt", "restricted-senders.txt", "blocked-senders.txt", "slots.toml",
104        // A conversation let through now from the card (Rust's appnotes.rs, `release_talk`).
105        "app-notes-talks.toml",
106    };
107
108    /** Bound by Android, the access given: notifications are decided and held only then. */
109    private static volatile boolean connected;
110
111    /** The listener's own thread, made in onCreate, ended in onDestroy: nothing is decided on Android's main thread. */
112    private HandlerThread thread;
113    /** That thread's queue: each notification, each review and the files' watch go through it, one at a time. */
114    private Handler worker;
115    /** The watches of Sioul's folders (`watch`), stopped when the listener is unbound or ends. */
116    private final List<FileObserver> watches = new ArrayList<>();
117    /** Each app's name as the phone shows it, by package, found once a process (`label`). */
118    private final Map<String, String> labels = new HashMap<>();
119    /** Android's words in a redacted notification, in the phone's language; read once (`redacted`). */
120    private String redactedWords;
121    /** The phone's default SMS app's package, "" for none (`smsApp`), and when it was last asked. */
122    private String smsApp = "";
123    private long smsAppAt;
124    /** What is held worked out again after Sioul's files changed: one runnable, so that a burst of changes runs it once. */
125    private final Runnable review = () -> review("files");
126    /** What is due sent after the step wrote it: one runnable, so that a burst of changes runs it once. */
127    private final Runnable answer = this::answer;
128
129    /** Rust's answer for one notification: {"hold": milliseconds, 0 to let it come, "why", "line"}. */
130    static native String nativeDecide(String json);
131
132    /** What is held, worked out again: {"snooze": [{"key", "ms"}]}, for the keys Android holds now. */
133    static native String nativeReview(String json);
134
135    /**
136     * Replies from your computers (crates/sioul-app/src/replies.rs): "due"
137     * (what is due now: {due: [{key, verb, notice}]}), "check" ({key, held,
138     * posted}: the live notification found for one, {go, crossed} or {go:
139     * false, state}), "taken" ({key}: written and flushed first, {go, words}),
140     * "result" ({key, state, crossed, held}), "removed" ({package, key}: a
141     * relayed notification gone, {shared}).
142     */
143    static native String nativeReply(String verb, String json);
144
145    /** Sioul's private folder for replies, in its state (Rust's `replies::private_folder`). */
146    private static final String REPLIES = "state/sioul/replies";
147    /** The file of what is due there, written whole by the background step. */
148    private static final String DUE = "due.jsonl";
149    /** The conversations answered from a computer lately, there (Rust's `replies::ANSWERED`): what is held of them comes now. */
150    private static final String ANSWERED = "answered.json";
151    /** Changes to what is due settle this long before it is read. */
152    private static final long DUE_SETTLE_MS = 300;
153
154    // ---------------------------------------------------------------- Rust's questions
155
156    /**
157     * Rust's question (android/main.cpp), from the window's process: the
158     * access, Android's pages for it and for each app's, channel's or
159     * conversation's notifications, the contacts' permission, what Android
160     * says of the conversations seen lately. Any thread.
161     */
162    static String call(Context context, String verb, String json)
163    {
164        try {
165            JSONObject asked = json == null || json.isEmpty() ? new JSONObject() : new JSONObject(json);
166            switch (verb) {
167            case "conversations":
168                return conversations(context).toString();
169            case "open-conversation":
170                return openConversation(context, asked) ? "true" : "false";
171            case "access":
172                return granted(context) ? "true" : "false";
173            case "restricted":
174                // Android 13 and later grey the access out for an app installed from a file, until
175                // allowed; whether they do for Sioul no app can read (Texts.java), so from Android 13 on.
176                return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU ? "true" : "false";
177            case "open-access":
178                return openAccess(context) ? "true" : "false";
179            case "open-info":
180                return open(context, new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + context.getPackageName()))) ? "true" : "false";
181            case "open-app":
182                return open(context, new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, asked.optString("package", ""))) ? "true" : "false";
183            case "open-channel":
184                return open(context, new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
185                    .putExtra(Settings.EXTRA_APP_PACKAGE, asked.optString("package", ""))
186                    .putExtra(Settings.EXTRA_CHANNEL_ID, asked.optString("channel", ""))) ? "true" : "false";
187            case "contacts":
188                return context.checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED ? "true" : "false";
189            default:
190                return null;
191            }
192        } catch (JSONException | RuntimeException e) {
193            Log.e(TAG, "Notes: " + verb + " failed: " + e);
194            return null;
195        }
196    }
197
198    /** This listener, as Android's settings and NotificationManager name it. */
199    private static ComponentName component(Context context)
200    {
201        return new ComponentName(context, AppNotes.class);
202    }
203
204    /** Whether the person gave Sioul "Notification access". */
205    static boolean granted(Context context)
206    {
207        NotificationManager notifications = context.getSystemService(NotificationManager.class);
208        return notifications != null && notifications.isNotificationListenerAccessGranted(component(context));
209    }
210
211    /** Android's page for Sioul's notification access: its own switch from Android 11, else the list. */
212    private static boolean openAccess(Context context)
213    {
214        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
215            Intent detail = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_DETAIL_SETTINGS)
216                .putExtra(Settings.EXTRA_NOTIFICATION_LISTENER_COMPONENT_NAME, component(context).flattenToString());
217            if (open(context, detail))
218                return true;
219        }
220        return open(context, new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS));
221    }
222
223    /** One of Android's pages opened, in a task of its own; false when this phone has no such page or refuses it. */
224    private static boolean open(Context context, Intent page)
225    {
226        try {
227            context.startActivity(page.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
228            return true;
229        } catch (ActivityNotFoundException | SecurityException e) {
230            Log.w(TAG, "Notes: no page " + page.getAction() + ": " + e);
231            return false;
232        }
233    }
234
235    // ---------------------------------------------------------------- conversations marked important
236
237    /**
238     * What Android says of each conversation seen lately, by Rust's key for it
239     * (crates/sioul-core/src/appnotes.rs, `talk_key` of its app and its
240     * shortcut): its app, its shortcut and its channel, so that the Other
241     * apps tab can open its page in Android, where a conversation set to
242     * Always through is marked important (priority), which Sioul's modes let
243     * through (PauseMode). This phone's alone, in Sioul's private storage,
244     * never shared; forgotten after a week unseen, as the tab forgets its name.
245     */
246    private static final String TALKS = "sioul-conversations";
247    /** A conversation unseen for eight days is forgotten (`forgotten`). */
248    private static final long TALK_KEPT_MS = 8L * 24 * 3600 * 1000;
249    /** At most this many conversations kept; past them, the oldest seen go first. */
250    private static final int TALKS_AT_MOST = 300;
251    /** Written again at most this often when nothing changed: not at each message. */
252    private static final long TALK_AGAIN_MS = 24L * 3600 * 1000;
253    /** Android's list of conversations (Settings ▸ Notifications ▸ Conversations), Android 11 and later. */
254    private static final String CONVERSATIONS_PAGE = "android.settings.CONVERSATION_SETTINGS";
255
256    /** Written by the listener's process, read by the window's. */
257    @SuppressWarnings("deprecation")
258    private static SharedPreferences talks(Context context)
259    {
260        return context.getSharedPreferences(TALKS, Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS);
261    }
262
263    /**
264     * A conversation's key as Rust's `talk_key` makes it: FNV-1a (64 bits)
265     * of its app, U+001F and its id, in hexadecimal. Pure, checked on a JVM
266     * against Rust's values (android/jvm-checks/ModeCheck.java).
267     */
268    static String talkKey(String pkg, String id)
269    {
270        long hash = 0xcbf29ce484222325L;
271        for (byte b : (pkg + "\u001f" + id).getBytes(StandardCharsets.UTF_8)) {
272            hash ^= b & 0xff;
273            hash *= 0x100000001b3L;
274        }
275        return String.format(Locale.ROOT, "%016x", hash);
276    }
277
278    /**
279     * The keys to forget among those kept (each a JSON text with its time
280     * "at"): those unseen for a week, then the oldest past the most kept.
281     * Pure, checked on a JVM (android/jvm-checks/ModeCheck.java).
282     */
283    static List<String> forgotten(Map<String, ?> kept, long now)
284    {
285        List<String> gone = new ArrayList<>();
286        List<Map.Entry<String, Long>> left = new ArrayList<>();
287        for (Map.Entry<String, ?> entry : kept.entrySet()) {
288            long at = 0;
289            try {
290                at = new JSONObject(String.valueOf(entry.getValue())).optLong("at", 0);
291            } catch (JSONException e) {
292                // Not one of ours: forgotten.
293            }
294            if (at <= 0 || now - at > TALK_KEPT_MS)
295                gone.add(entry.getKey());
296            else
297                left.add(new java.util.AbstractMap.SimpleEntry<>(entry.getKey(), at));
298        }
299        if (left.size() > TALKS_AT_MOST) {
300            left.sort((a, b) -> Long.compare(a.getValue(), b.getValue()));
301            for (int i = 0; i < left.size() - TALKS_AT_MOST; i++)
302                gone.add(left.get(i).getKey());
303        }
304        return gone;
305    }
306
307    /**
308     * A conversation seen (the listener, on its worker thread): its app, its
309     * shortcut, the channel it was posted on (its parent's when Android gave
310     * it a channel of its own), whether Android takes it as a conversation,
311     * whether it is marked important there, and whether it has a page of its
312     * own there yet (customized once). Written when something changed, or
313     * once a day.
314     */
315    private static void remember(Context context, String pkg, String shortcut, String channel, NotificationChannel given, boolean conversation, long now)
316    {
317        boolean important = false;
318        boolean own = false;
319        String parent = channel;
320        if (given != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
321            important = given.isImportantConversation();
322            own = given.getConversationId() != null;
323            if (given.getParentChannelId() != null)
324                parent = given.getParentChannelId();
325        }
326        String key = talkKey(pkg, shortcut);
327        SharedPreferences talks = talks(context);
328        try {
329            String before = talks.getString(key, null);
330            if (before != null) {
331                JSONObject was = new JSONObject(before);
332                boolean same = pkg.equals(was.optString("package")) && shortcut.equals(was.optString("shortcut")) && parent.equals(was.optString("channel"))
333                    && conversation == was.optBoolean("conversation") && important == was.optBoolean("important") && own == was.optBoolean("own");
334                if (same && now - was.optLong("at", 0) < TALK_AGAIN_MS)
335                    return;
336            }
337            JSONObject seen = new JSONObject().put("package", pkg).put("shortcut", shortcut).put("channel", parent)
338                .put("conversation", conversation).put("important", important).put("own", own).put("at", now);
339            SharedPreferences.Editor edit = talks.edit().putString(key, seen.toString());
340            if (before == null)
341                for (String old : forgotten(talks.getAll(), now))
342                    if (!old.equals(key))
343                        edit.remove(old);
344            edit.apply();
345        } catch (JSONException | RuntimeException e) {
346            Log.w(TAG, "Notes: a conversation not kept: " + e);
347        }
348    }
349
350    /** One conversation as kept, by Rust's key; null when not seen lately. */
351    private static JSONObject talk(Context context, String key)
352    {
353        String kept = key.isEmpty() ? null : talks(context).getString(key, null);
354        try {
355            return kept == null ? null : new JSONObject(kept);
356        } catch (JSONException e) {
357            return null;
358        }
359    }
360
361    /**
362     * What Android says of the conversations seen lately, for the Other apps
363     * tab: {api, conversations: {key: {important, page}}}: marked important
364     * there; `page`, Android takes it as a conversation, whose page Sioul
365     * can open (Android 11 and later).
366     */
367    static JSONObject conversations(Context context) throws JSONException
368    {
369        JSONObject known = new JSONObject();
370        long now = System.currentTimeMillis();
371        for (Map.Entry<String, ?> entry : talks(context).getAll().entrySet()) {
372            try {
373                JSONObject seen = new JSONObject(String.valueOf(entry.getValue()));
374                if (now - seen.optLong("at", 0) > TALK_KEPT_MS)
375                    continue;
376                boolean page = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && seen.optBoolean("conversation");
377                known.put(entry.getKey(), new JSONObject().put("important", seen.optBoolean("important")).put("page", page));
378            } catch (JSONException e) {
379                // Not one of ours.
380            }
381        }
382        return new JSONObject().put("api", Build.VERSION.SDK_INT).put("conversations", known);
383    }
384
385    /**
386     * Which of Android's pages a conversation is opened at, in order, the
387     * next one when a page does not open: "conversation", its own page
388     * (where Priority is), once Android gave it one; "conversations", the
389     * list of conversations, where one not changed yet shows among the
390     * recent ones; "app", the app's notifications. Pure, checked on a JVM
391     * (android/jvm-checks/ModeCheck.java).
392     */
393    static String[] pages(int api, boolean conversation, boolean own, boolean channel)
394    {
395        if (api < 30 || !conversation)
396            return new String[] { "app" };
397        if (own && channel)
398            return new String[] { "conversation", "conversations", "app" };
399        return channel ? new String[] { "conversations", "conversation", "app" } : new String[] { "conversations", "app" };
400    }
401
402    /**
403     * A conversation's page in Android: {key} (Rust's key for it), or
404     * {package, shortcut, channel}. False when no page opened.
405     */
406    private static boolean openConversation(Context context, JSONObject asked)
407    {
408        JSONObject seen = talk(context, asked.optString("key", ""));
409        String pkg = seen != null ? seen.optString("package") : asked.optString("package", "");
410        String shortcut = seen != null ? seen.optString("shortcut") : asked.optString("shortcut", "");
411        String channel = seen != null ? seen.optString("channel") : asked.optString("channel", "");
412        boolean conversation = seen == null ? !shortcut.isEmpty() : seen.optBoolean("conversation");
413        boolean own = seen != null && seen.optBoolean("own");
414        if (pkg.isEmpty())
415            return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && open(context, new Intent(CONVERSATIONS_PAGE));
416        for (String page : pages(Build.VERSION.SDK_INT, conversation && !shortcut.isEmpty(), own, !channel.isEmpty())) {
417            Intent intent;
418            switch (page) {
419            case "conversation":
420                intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, pkg)
421                    .putExtra(Settings.EXTRA_CHANNEL_ID, channel).putExtra(Settings.EXTRA_CONVERSATION_ID, shortcut);
422                break;
423            case "conversations":
424                intent = new Intent(CONVERSATIONS_PAGE);
425                break;
426            default:
427                intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, pkg);
428            }
429            if (open(context, intent))
430                return true;
431        }
432        return false;
433    }
434
435    // ---------------------------------------------------------------- the listener
436
437    /** The listener's own thread started, before Android binds it. */
438    @Override
439    public void onCreate()
440    {
441        super.onCreate();
442        thread = new HandlerThread("sioul-notes", Process.THREAD_PRIORITY_BACKGROUND);
443        thread.start();
444        worker = new Handler(thread.getLooper());
445    }
446
447    /** Ended: nothing held from here on, the watches stopped, the thread let finish what it was doing. */
448    @Override
449    public void onDestroy()
450    {
451        connected = false;
452        unwatch();
453        thread.quitSafely();
454        super.onDestroy();
455    }
456
457    /**
458     * Bound, the access given (at the phone's start, when the access is
459     * given, after Android restarted the listener): Sioul's files watched,
460     * and what Android holds already worked out again.
461     */
462    @Override
463    public void onListenerConnected()
464    {
465        connected = true;
466        worker.post(() -> {
467            watch();
468            review("connected");
469            // What became due while Android had not bound the listener yet.
470            answer();
471        });
472    }
473
474    /**
475     * The phone's do-not-disturb changed, whatever changed it: Android tells a
476     * bound listener of every change (NotificationManagerService,
477     * notifyInterruptionFilterChanged), a word surer than the broadcast to
478     * the packages with Do Not Disturb access. Decided in Sioul's own process,
479     * where its modes are kept (DndReceiver.HEARD), as the broadcast is.
480     */
481    @Override
482    public void onInterruptionFilterChanged(int interruptionFilter)
483    {
484        DndReceiver.forward(getApplicationContext());
485        // The texts held in the shade without a sound (◑) come once the phone is silenced, and wait otherwise.
486        worker.post(() -> review("filter"));
487    }
488
489    /** Unbound: the access taken back, or Android stopping the listener. */
490    @Override
491    public void onListenerDisconnected()
492    {
493        // The access taken back, or Android unbinding: nothing is held from here until it binds again.
494        connected = false;
495        worker.post(this::unwatch);
496    }
497
498    /**
499     * Another app's notification, posted or updated: asked about on the
500     * worker thread with the ranking Android gave with it. Never Sioul's own,
501     * an ongoing one (a call, music, a download), or one that cannot be cleared.
502     */
503    @Override
504    public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankings)
505    {
506        if (sbn == null || getPackageName().equals(sbn.getPackageName()))
507            return;
508        Notification n = sbn.getNotification();
509        int kept = Notification.FLAG_ONGOING_EVENT | Notification.FLAG_FOREGROUND_SERVICE | Notification.FLAG_NO_CLEAR;
510        if (n == null || (n.flags & kept) != 0 || !sbn.isClearable())
511            return;
512        // The ranking as Android gave it with this notification: its channel, whether it rang.
513        Ranking ranking = new Ranking();
514        boolean ranked = rankings != null && rankings.getRanking(sbn.getKey(), ranking);
515        Ranking given = ranked ? ranking : null;
516        worker.post(() -> decide(sbn, given));
517    }
518
519    /**
520     * A notification gone, as Android says it: for one relayed to your
521     * computers, its going said there (Rust's `replies::went`), so that they
522     * no longer offer its Reply. Never for Sioul's own holding
523     * (REASON_SNOOZED), which is no going.
524     */
525    @Override
526    public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankings, int reason)
527    {
528        if (sbn == null || reason == REASON_SNOOZED || getPackageName().equals(sbn.getPackageName()))
529            return;
530        String pkg = sbn.getPackageName();
531        String key = sbn.getKey();
532        worker.post(() -> removed(pkg, key));
533    }
534
535    /** A notification's going said to Rust, and a step soon when your computers have it to read. On the worker thread. */
536    private void removed(String pkg, String key)
537    {
538        try {
539            DoseAlarms.load(getApplicationContext());
540            String answer = nativeReply("removed", new JSONObject().put("package", pkg).put("key", key).toString());
541            if (answer != null && !answer.isEmpty() && !"null".equals(answer) && new JSONObject(answer).optBoolean("shared", false))
542                StepService.soon(getApplicationContext());
543        } catch (Throwable e) {
544            Log.w(TAG, "Replies: a notification's going was not said: " + e.getClass().getSimpleName());
545        }
546    }
547
548    /** Rust asked, the answer followed: held so long, or left as it came. On the worker thread. */
549    private void decide(StatusBarNotification sbn, Ranking ranking)
550    {
551        if (!connected)
552            return;
553        try {
554            String said = describe(sbn, ranking).toString();
555            DoseAlarms.load(getApplicationContext());
556            String answer = nativeDecide(said);
557            if (answer == null || answer.isEmpty() || "null".equals(answer))
558                return;
559            JSONObject decided = new JSONObject(answer);
560            long hold = decided.optLong("hold", 0);
561            if (hold > 0)
562                snooze(sbn.getKey(), hold);
563            // Rust's calm line for the log, kinds, counts and times only: at each hold, with what
564            // decided it (`hold_line`); at each one held come back at its time (`log_line`).
565            String line = decided.optString("line", "");
566            if (!line.isEmpty())
567                Log.i(TAG, line);
568            // A line written for your computers (crates/sioul-app/src/phonemsgs.rs): shared at a step soon.
569            if (decided.optBoolean("shared", false))
570                StepService.soon(getApplicationContext());
571        } catch (Throwable e) {
572            // Sioul's library that does not load, Rust that fails: the notification stays as it came.
573            Log.e(TAG, "Notes: a notification was not decided; it comes as sent.", e);
574        }
575    }
576
577    /** A notification held by Android for `ms` (a second at least), then given back whole, as it came. */
578    private void snooze(String key, long ms)
579    {
580        try {
581            snoozeNotification(key, Math.max(1000, ms));
582        } catch (RuntimeException e) {
583            // The access taken back meanwhile: nothing held.
584            Log.w(TAG, "Notes: not held: " + e);
585        }
586    }
587
588    /**
589     * What is held, worked out again; Android told the new times. On the
590     * worker thread. One calm line in the log each time (`adb logcat -s
591     * sioul`): how many are held and released, by kind, why, and the next
592     * release (Rust's `log_line`: kinds, counts and times, never a name, a
593     * number, an app or a message's words); or, when Rust could not answer,
594     * that in plain words.
595     */
596    private void review(String reason)
597    {
598        if (!connected)
599            return;
600        try {
601            StatusBarNotification[] held = getSnoozedNotifications();
602            JSONArray keys = new JSONArray();
603            if (held != null)
604                for (StatusBarNotification sbn : held)
605                    keys.put(sbn.getKey());
606            DoseAlarms.load(getApplicationContext());
607            // Whether the phone's do-not-disturb silences every text now: a text whose cell says ◑ comes then,
608            // without a sound. Android ranks no held notification, so the filter and its policy say it.
609            boolean silenced = silencesTexts();
610            String answer = nativeReview(new JSONObject().put("snoozed", keys).put("reason", reason).put("silenced", silenced).toString());
611            if (answer == null || answer.isEmpty() || "null".equals(answer)) {
612                Log.w(TAG, "Notes: review (" + reason + "): what is held could not be worked out again; each notification keeps the time it had.");
613                return;
614            }
615            JSONObject reviewed = new JSONObject(answer);
616            JSONArray again = reviewed.optJSONArray("snooze");
617            for (int i = 0; again != null && i < again.length(); i++) {
618                JSONObject one = again.optJSONObject(i);
619                if (one != null && !one.optString("key", "").isEmpty())
620                    snooze(one.optString("key"), one.optLong("ms", 1000));
621            }
622            String line = reviewed.optString("line", "");
623            if (!line.isEmpty())
624                Log.i(TAG, line);
625        } catch (Throwable e) {
626            Log.e(TAG, "Notes: review (" + reason + "): what is held could not be worked out again; each notification keeps the time it had.", e);
627        }
628    }
629
630    // ---------------------------------------------------------------- Sioul's files watched
631
632    /** The folders of Sioul's state, configuration and data, watched for the files that move what is held. */
633    private void watch()
634    {
635        unwatch();
636        File files = getFilesDir();
637        for (String folder : new String[] { "state/sioul", "config/sioul", "data/sioul", "data/sioul/time" }) {
638            String path = new File(files, folder).getAbsolutePath();
639            @SuppressWarnings("deprecation")
640            FileObserver observer = new FileObserver(path, FileObserver.CLOSE_WRITE | FileObserver.MOVED_TO) {
641                @Override
642                public void onEvent(int event, String name)
643                {
644                    if (name == null)
645                        return;
646                    for (String watched : WATCHED) {
647                        if (watched.equals(name)) {
648                            worker.removeCallbacks(review);
649                            worker.postDelayed(review, SETTLE_MS);
650                            return;
651                        }
652                    }
653                }
654            };
655            observer.startWatching();
656            watches.add(observer);
657        }
658        watchReplies();
659    }
660
661    /**
662     * The private folder of replies watched for what is due (made first, so
663     * that it can be watched before the step writes there): the background
664     * step writes it whole beside, then puts it in place. And for the
665     * conversations answered lately: what Sioul holds of one is worked out
666     * again, to come now.
667     */
668    private void watchReplies()
669    {
670        File folder = new File(getFilesDir(), REPLIES);
671        if (!folder.isDirectory() && !folder.mkdirs())
672            return;
673        @SuppressWarnings("deprecation")
674        FileObserver observer = new FileObserver(folder.getAbsolutePath(), FileObserver.CLOSE_WRITE | FileObserver.MOVED_TO) {
675            @Override
676            public void onEvent(int event, String name)
677            {
678                if (DUE.equals(name)) {
679                    worker.removeCallbacks(answer);
680                    worker.postDelayed(answer, DUE_SETTLE_MS);
681                } else if (ANSWERED.equals(name)) {
682                    worker.removeCallbacks(review);
683                    worker.postDelayed(review, SETTLE_MS);
684                }
685            }
686        };
687        observer.startWatching();
688        watches.add(observer);
689    }
690
691    /** The watches of Sioul's folders stopped. */
692    private void unwatch()
693    {
694        for (FileObserver observer : watches)
695            observer.stopWatching();
696        watches.clear();
697    }
698
699    // ---------------------------------------------------------------- replies from your computers
700
701    /**
702     * Each reply or Mark as read due now (handed over by the background
703     * step, which decided it once): sent through its notification's own
704     * action, or said why not ({@link #answerOne}); a step soon, so that what
705     * became of it reaches your computers. On the worker thread.
706     */
707    private void answer()
708    {
709        if (!connected || Build.VERSION.SDK_INT < Build.VERSION_CODES.S)
710            return;
711        try {
712            DoseAlarms.load(getApplicationContext());
713            String answer = nativeReply("due", "{}");
714            if (answer == null || answer.isEmpty() || "null".equals(answer))
715                return;
716            JSONArray due = new JSONObject(answer).optJSONArray("due");
717            boolean said = false;
718            for (int i = 0; due != null && i < due.length(); i++) {
719                JSONObject one = due.optJSONObject(i);
720                if (one != null)
721                    said |= answerOne(one);
722            }
723            if (said)
724                StepService.soon(getApplicationContext());
725        } catch (Throwable e) {
726            // Sioul's library that does not load, Rust that fails: nothing sent; the step says it in time.
727            Log.e(TAG, "Replies: what is due could not be read; nothing was sent.", e);
728        }
729    }
730
731    /**
732     * One due: its notification found again live by the name the line gave
733     * it, among what Android shows, then what Sioul holds, of this phone's
734     * own profile; Rust's check that it is still the conversation answered;
735     * its Reply (or Mark as read) chosen by what it means and refused when
736     * it may not go; "taken" written and flushed; then sent once, as the
737     * shade sends one. Whether anything was said to Rust.
738     */
739    private boolean answerOne(JSONObject due) throws JSONException
740    {
741        String key = due.optString("key", "");
742        String notice = due.optString("notice", "");
743        boolean fill = !"read".equals(due.optString("verb", ""));
744        if (key.isEmpty() || notice.isEmpty())
745            return false;
746        StatusBarNotification live = null;
747        boolean held = false;
748        StatusBarNotification[][] lists = { getActiveNotifications(), getSnoozedNotifications() };
749        for (int list = 0; list < lists.length && live == null; list++) {
750            StatusBarNotification[] sbns = lists[list] == null ? new StatusBarNotification[0] : lists[list];
751            String[] packages = new String[sbns.length];
752            String[] keys = new String[sbns.length];
753            boolean[] own = new boolean[sbns.length];
754            for (int i = 0; i < sbns.length; i++) {
755                packages[i] = sbns[i] == null ? null : sbns[i].getPackageName();
756                keys[i] = sbns[i] == null ? null : sbns[i].getKey();
757                own[i] = sbns[i] != null && Process.myUserHandle().equals(sbns[i].getUser());
758            }
759            int found = Replies.live(packages, keys, own, notice);
760            if (found >= 0) {
761                live = sbns[found];
762                held = list == 1;
763            }
764        }
765        if (live == null)
766            return result(key, "gone", false, false);
767        Ranking ranking = ranking(live.getKey());
768        JSONObject checked = new JSONObject(nativeReply("check", new JSONObject().put("key", key).put("held", held).put("posted", describe(live, ranking)).toString()));
769        if (!checked.optBoolean("go", false))
770            return result(key, checked.optString("state", "gone"), false, held);
771        boolean crossed = checked.optBoolean("crossed", false);
772        List<Replies.Found> found = Replies.found(live.getNotification());
773        boolean conversation = ranking != null && ranking.isConversation();
774        Replies.Choice choice = Replies.choose(Replies.acts(found), conversation, live.getPackageName().equals(smsApp()));
775        Replies.Act act = fill ? choice.reply : choice.read;
776        KeyguardManager keyguard = getSystemService(KeyguardManager.class);
777        String refused = Replies.refusal(act, fill, keyguard != null && keyguard.isDeviceLocked());
778        if (!refused.isEmpty())
779            return result(key, "none".equals(refused) ? (fill ? "no-reply" : "no-read") : refused, crossed, held);
780        // "taken" first, flushed: after a crash, the doubt, never a second sending.
781        JSONObject taken = new JSONObject(nativeReply("taken", new JSONObject().put("key", key).toString()));
782        if (!taken.optBoolean("go", false))
783            return taken.optBoolean("said", false);
784        try {
785            Replies.send(this, Replies.of(found, act), taken.optString("words", ""), fill);
786            return result(key, "handed", crossed, held);
787        } catch (PendingIntent.CanceledException e) {
788            return result(key, "gone", crossed, held);
789        } catch (RuntimeException e) {
790            Log.w(TAG, "Replies: Android refused the sending: " + e.getClass().getSimpleName());
791            return result(key, "failed", crossed, held);
792        }
793    }
794
795    /** What became of one, said to Rust, which writes it (never the words); always true. */
796    private boolean result(String key, String state, boolean crossed, boolean held) throws JSONException
797    {
798        nativeReply("result", new JSONObject().put("key", key).put("state", state).put("crossed", crossed).put("held", held).toString());
799        // States only, in the phone's log: never a word, a name, an app.
800        Log.i(TAG, "Replies: one " + state + (crossed ? ", crossing newer messages" : "") + (held ? ", among those held" : "") + ".");
801        return true;
802    }
803
804    /** Whether do-not-disturb silences every text now (`textsSilenced`), as Android says it. */
805    private boolean silencesTexts()
806    {
807        int filter = getCurrentInterruptionFilter();
808        NotificationManager.Policy policy = null;
809        if (filter == NotificationListenerService.INTERRUPTION_FILTER_PRIORITY && Build.VERSION.SDK_INT >= 35) {
810            try {
811                policy = getSystemService(NotificationManager.class).getConsolidatedNotificationPolicy();
812            } catch (RuntimeException e) {
813                policy = null;
814            }
815        }
816        return policy == null ? textsSilenced(filter, false, 0, 0) : textsSilenced(filter, true, policy.priorityCategories, policy.priorityConversationSenders);
817    }
818
819    /**
820     * Whether a filter silences every text (review of part 4, S-2). Android
821     * ranks no held notification, so a held text cannot be asked whether it
822     * would ring: "nothing" and "alarms only" silence every text; "priority"
823     * only when its policy, as Android 15 and later give it in force
824     * (`getConsolidatedNotificationPolicy`, Sioul's mode or yours), lets
825     * neither messages nor conversations ring; an older Android's "priority"
826     * may let starred contacts' texts ring, so it reads as not silenced, and
827     * a text held then waits (docs/android.md). At posting, each text's own
828     * ranking says it (`intercepted`).
829     */
830    static boolean textsSilenced(int filter, boolean known, int categories, int conversationSenders)
831    {
832        if (filter == NotificationListenerService.INTERRUPTION_FILTER_NONE || filter == NotificationListenerService.INTERRUPTION_FILTER_ALARMS)
833            return true;
834        if (filter != NotificationListenerService.INTERRUPTION_FILTER_PRIORITY || !known)
835            return false;
836        boolean texts = (categories & NotificationManager.Policy.PRIORITY_CATEGORY_MESSAGES) != 0;
837        boolean conversations = (categories & NotificationManager.Policy.PRIORITY_CATEGORY_CONVERSATIONS) != 0 && conversationSenders != NotificationManager.Policy.CONVERSATION_SENDERS_NONE;
838        return !texts && !conversations;
839    }
840
841    /** The ranking Android gives a notification now; null when it gives none (a held one). */
842    private Ranking ranking(String key)
843    {
844        try {
845            RankingMap rankings = getCurrentRanking();
846            Ranking ranking = new Ranking();
847            return rankings != null && rankings.getRanking(key, ranking) ? ranking : null;
848        } catch (RuntimeException e) {
849            return null;
850        }
851    }
852
853    // ---------------------------------------------------------------- what Android says of a notification
854
855    /** The notification as Rust reads it (crates/sioul-core/src/appnotes.rs, `Posted`). */
856    @SuppressWarnings("deprecation")
857    private JSONObject describe(StatusBarNotification sbn, Ranking ranking) throws JSONException
858    {
859        Notification n = sbn.getNotification();
860        Bundle extras = n.extras == null ? new Bundle() : n.extras;
861        String app = label(sbn.getPackageName(), extras);
862        JSONObject out = new JSONObject();
863        out.put("key", sbn.getKey());
864        out.put("package", sbn.getPackageName());
865        out.put("app", app);
866        out.put("category", n.category == null ? "" : n.category);
867        out.put("tag", sbn.getTag() == null ? "" : sbn.getTag());
868        out.put("group", n.getGroup() == null ? "" : n.getGroup());
869        out.put("summary", (n.flags & Notification.FLAG_GROUP_SUMMARY) != 0);
870        out.put("full_screen", n.fullScreenIntent != null);
871        out.put("template", text(extras, Notification.EXTRA_TEMPLATE));
872        String title = text(extras, Notification.EXTRA_TITLE);
873        String body = text(extras, Notification.EXTRA_TEXT);
874        out.put("title", title);
875        out.put("text", body);
876        out.put("sub", text(extras, Notification.EXTRA_SUB_TEXT));
877        out.put("big", text(extras, Notification.EXTRA_BIG_TEXT));
878        JSONArray lines = new JSONArray();
879        CharSequence[] textLines = extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES);
880        if (textLines != null)
881            for (CharSequence line : textLines)
882                lines.put(line == null ? "" : line.toString());
883        out.put("lines", lines);
884        out.put("conversation", text(extras, Notification.EXTRA_CONVERSATION_TITLE));
885        out.put("group_conversation", extras.getBoolean(Notification.EXTRA_IS_GROUP_CONVERSATION, false));
886        String shortcut = n.getShortcutId() == null ? "" : n.getShortcutId();
887        boolean conversation = false;
888        if (ranking != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
889            conversation = ranking.isConversation();
890            ShortcutInfo info = ranking.getConversationShortcutInfo();
891            if (shortcut.isEmpty() && info != null && info.getId() != null)
892                shortcut = info.getId();
893        }
894        out.put("shortcut", shortcut);
895        out.put("is_conversation", conversation);
896        String channel = n.getChannelId() == null ? "" : n.getChannelId();
897        out.put("channel", channel);
898        NotificationChannel given = ranking == null ? null : ranking.getChannel();
899        out.put("channel_name", given == null || given.getName() == null ? "" : given.getName().toString());
900        // Its page in Android, for the tab: where a conversation Always through is marked important.
901        if (!shortcut.trim().isEmpty())
902            remember(this, sbn.getPackageName(), shortcut.trim(), channel, given, conversation, System.currentTimeMillis());
903        boolean alerted = ranking != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
904            && ranking.getLastAudiblyAlertedMillis() > 0 && System.currentTimeMillis() - ranking.getLastAudiblyAlertedMillis() < ALERTED_MS;
905        out.put("alerted", alerted);
906        out.put("importance", ranking == null ? -1 : ranking.getImportance());
907        out.put("intercepted", ranking != null && !ranking.matchesInterruptionFilter());
908        out.put("redacted", redacted(title, body, app));
909        out.put("sms_app", sbn.getPackageName().equals(smsApp()));
910        boolean contacts = checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED;
911        out.put("messages", messages(extras, contacts));
912        Object user = extras.getParcelable(Notification.EXTRA_MESSAGING_PERSON);
913        if (user instanceof Person) {
914            Person me = (Person) user;
915            out.put("user", new JSONObject().put("name", me.getName() == null ? "" : me.getName().toString()).put("key", me.getKey() == null ? "" : me.getKey()));
916        }
917        out.put("people", people(extras, contacts));
918        // For your computers, when its app sends to them (crates/sioul-core/src/phonemsgs.rs, `Extra`):
919        // whether its app keeps it secret, when it was sent and posted, whether it shows a picture (never the picture).
920        PhoneMessages.notice(out, n.visibility, n.when, sbn.getPostTime(), extras.containsKey(Notification.EXTRA_PICTURE) || extras.containsKey("android.pictureIcon"));
921        // What it offers a reply from your computers (Replies.java): nothing for another profile's,
922        // which the listener never answers; and what went through its Reply lately, the person's own
923        // words, read by Rust in memory only, to tell a reply shown.
924        boolean own = Process.myUserHandle().equals(sbn.getUser());
925        out.put("offer", own ? Replies.offer(n, conversation, sbn.getPackageName().equals(smsApp())) : Replies.nothing());
926        JSONArray history = new JSONArray();
927        CharSequence[] sent = extras.getCharSequenceArray(Notification.EXTRA_REMOTE_INPUT_HISTORY);
928        if (sent != null)
929            for (CharSequence one : sent)
930                if (one != null)
931                    history.put(one.toString());
932        out.put("history", history);
933        return out;
934    }
935
936    /** A text of the extras, a CharSequence or a String; "" when none. */
937    private static String text(Bundle extras, String key)
938    {
939        CharSequence value = extras.getCharSequence(key);
940        return value == null ? "" : value.toString();
941    }
942
943    /**
944     * A conversation's messages (MessagingStyle), each with its sender as the
945     * app names them; the phone's address book asked about the last one's
946     * sender only, the one Rust judges.
947     */
948    @SuppressWarnings("deprecation")
949    private JSONArray messages(Bundle extras, boolean contacts) throws JSONException
950    {
951        JSONArray out = new JSONArray();
952        Parcelable[] bundles = extras.getParcelableArray(Notification.EXTRA_MESSAGES);
953        if (bundles == null)
954            return out;
955        List<Notification.MessagingStyle.Message> messages;
956        try {
957            messages = Notification.MessagingStyle.Message.getMessagesFromBundleArray(bundles);
958        } catch (RuntimeException | LinkageError e) {
959            return out;
960        }
961        int last = -1;
962        for (int i = 0; i < messages.size(); i++)
963            if (messages.get(i).getSenderPerson() != null)
964                last = i;
965        for (int i = 0; i < messages.size(); i++) {
966            Notification.MessagingStyle.Message message = messages.get(i);
967            JSONObject one = new JSONObject();
968            one.put("text", message.getText() == null ? "" : message.getText().toString());
969            // When it was sent, and whether it is a picture (its address stays here): for your computers.
970            PhoneMessages.message(one, message.getTimestamp(), message.getDataMimeType());
971            Person sender = message.getSenderPerson();
972            if (sender != null)
973                one.put("sender", person(sender.getName(), sender.getKey(), sender.getUri(), sender.isBot(), contacts && i == last));
974            out.put(one);
975        }
976        return out;
977    }
978
979    /**
980     * The people a notification names (EXTRA_PEOPLE_LIST), and the older
981     * addresses (EXTRA_PEOPLE); the phone's address book asked about the
982     * first only, the one Rust judges when no message names a sender.
983     */
984    @SuppressWarnings("deprecation")
985    private JSONArray people(Bundle extras, boolean contacts) throws JSONException
986    {
987        JSONArray out = new JSONArray();
988        ArrayList<Person> people = extras.getParcelableArrayList(Notification.EXTRA_PEOPLE_LIST);
989        if (people != null)
990            for (Person p : people)
991                if (p != null)
992                    out.put(person(p.getName(), p.getKey(), p.getUri(), p.isBot(), contacts && out.length() == 0));
993        String[] older = extras.getStringArray(Notification.EXTRA_PEOPLE);
994        if (older != null)
995            for (String uri : older)
996                if (uri != null && !uri.isEmpty())
997                    out.put(person(null, null, uri, false, contacts && out.length() == 0));
998        return out;
999    }
1000
1001    /**
1002     * A person, with what the phone's address book says of them when Sioul
1003     * may read it: a contact's lookup address resolved to its numbers and
1004     * addresses; a name alone, the numbers of the one contact of that name
1005     * (`by_name`: counted only for apps that name people as the address book
1006     * does, Rust decides).
1007     */
1008    private JSONObject person(CharSequence name, String key, String uri, boolean bot, boolean contacts) throws JSONException
1009    {
1010        JSONObject out = new JSONObject();
1011        String named = name == null ? "" : name.toString();
1012        out.put("name", named);
1013        out.put("key", key == null ? "" : key);
1014        out.put("uri", uri == null ? "" : uri);
1015        out.put("bot", bot);
1016        if (!contacts)
1017            return out;
1018        long id = -1;
1019        String by = "numbers";
1020        if (uri != null && uri.startsWith("content://com.android.contacts/")) {
1021            id = contactOfLookup(uri);
1022        } else if ((uri == null || uri.isEmpty()) && !named.trim().isEmpty()) {
1023            id = contactOfName(named.trim());
1024            by = "by_name";
1025        }
1026        if (id < 0)
1027            return out;
1028        out.put(by, data(id, ContactsContract.CommonDataKinds.Phone.CONTENT_URI, ContactsContract.CommonDataKinds.Phone.CONTACT_ID, ContactsContract.CommonDataKinds.Phone.NUMBER));
1029        out.put("by_name".equals(by) ? "emails_by_name" : "emails", data(id, ContactsContract.CommonDataKinds.Email.CONTENT_URI, ContactsContract.CommonDataKinds.Email.CONTACT_ID, ContactsContract.CommonDataKinds.Email.ADDRESS));
1030        return out;
1031    }
1032
1033    /** The contact a lookup address names; -1 when none. */
1034    private long contactOfLookup(String uri)
1035    {
1036        try {
1037            Uri contact = ContactsContract.Contacts.lookupContact(getContentResolver(), Uri.parse(uri));
1038            if (contact == null)
1039                return -1;
1040            try (Cursor c = getContentResolver().query(contact, new String[] { ContactsContract.Contacts._ID }, null, null, null)) {
1041                return c != null && c.moveToFirst() ? c.getLong(0) : -1;
1042            }
1043        } catch (RuntimeException e) {
1044            return -1;
1045        }
1046    }
1047
1048    /** The one contact with exactly this name; -1 when none, or when several share it. */
1049    private long contactOfName(String name)
1050    {
1051        try (Cursor c = getContentResolver().query(ContactsContract.Contacts.CONTENT_URI, new String[] { ContactsContract.Contacts._ID },
1052                                                    ContactsContract.Contacts.DISPLAY_NAME + " = ?", new String[] { name }, null)) {
1053            if (c == null || c.getCount() != 1 || !c.moveToFirst())
1054                return -1;
1055            return c.getLong(0);
1056        } catch (RuntimeException e) {
1057            return -1;
1058        }
1059    }
1060
1061    /** A contact's numbers or addresses. */
1062    private JSONArray data(long id, Uri table, String contact, String column)
1063    {
1064        JSONArray out = new JSONArray();
1065        try (Cursor c = getContentResolver().query(table, new String[] { column }, contact + " = ?", new String[] { Long.toString(id) }, null)) {
1066            while (c != null && c.moveToNext()) {
1067                String value = c.getString(0);
1068                if (value != null && !value.trim().isEmpty())
1069                    out.put(value.trim());
1070            }
1071        } catch (RuntimeException e) {
1072            // Not readable: nothing known.
1073        }
1074        return out;
1075    }
1076
1077    /** The app's name as the phone shows it, kept; else the one its notification carries; else its package. */
1078    @SuppressWarnings("deprecation")
1079    private String label(String pkg, Bundle extras)
1080    {
1081        String known = labels.get(pkg);
1082        if (known != null)
1083            return known;
1084        PackageManager packages = getPackageManager();
1085        String label = pkg;
1086        try {
1087            label = packages.getApplicationLabel(packages.getApplicationInfo(pkg, 0)).toString();
1088        } catch (PackageManager.NameNotFoundException e) {
1089            // Not visible to Sioul (Android 11's package visibility): the notification's own copy of it.
1090            Object info = extras.get("android.appInfo");
1091            if (info instanceof ApplicationInfo)
1092                label = ((ApplicationInfo) info).loadLabel(packages).toString();
1093        }
1094        labels.put(pkg, label);
1095        return label;
1096    }
1097
1098    /**
1099     * Whether Android gave Sioul a redacted copy (Android 15, for a
1100     * notification its assistant judged sensitive: a code): its title is the
1101     * app's name and its text Android's own words for it.
1102     */
1103    private boolean redacted(String title, String text, String app)
1104    {
1105        if (redactedWords == null) {
1106            Resources system = Resources.getSystem();
1107            int id = system.getIdentifier(REDACTED, "string", "android");
1108            redactedWords = id == 0 ? "" : system.getString(id);
1109        }
1110        return !redactedWords.isEmpty() && redactedWords.contentEquals(text) && app.contentEquals(title);
1111    }
1112
1113    /** The phone's default SMS app, asked again each minute at most. */
1114    private String smsApp()
1115    {
1116        long now = System.currentTimeMillis();
1117        if (now - smsAppAt > 60_000) {
1118            String found = null;
1119            try {
1120                found = Telephony.Sms.getDefaultSmsPackage(this);
1121            } catch (RuntimeException e) {
1122                // No telephony on this device.
1123            }
1124            smsApp = found == null ? "" : found;
1125            smsAppAt = now;
1126        }
1127        return smsApp;
1128    }
1129}