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}