001// SPDX-License-Identifier: GPL-3.0-or-later 002// Copyright © 2026 Aurélien Pierre 003 004package com.aurelienpierre.sioul; 005 006import android.app.Notification; 007import android.app.PendingIntent; 008import android.app.RemoteInput; 009import android.content.Context; 010import android.content.Intent; 011import android.os.Build; 012import android.os.Bundle; 013import android.os.Parcelable; 014 015import org.json.JSONException; 016import org.json.JSONObject; 017 018import java.util.ArrayList; 019import java.util.List; 020 021/** 022 * A reply from your computers sent through a notification's own Reply, as 023 * the phone's shade sends one typed under it (docs/android.md, "Replies from 024 * your computers"; Rust's side: crates/sioul-app/src/replies.rs, 025 * crates/sioul-core/src/replies.rs). Here: which of a notification's actions 026 * is its Reply and which its Mark as read, found by what they mean, never by 027 * their titles nor their places ({@link #choose}); which may not be sent 028 * ({@link #refusal}); and the sending, the shade's own way ({@link #send}). 029 * 030 * Nothing else is ever sent: no Delete, Archive, Block, Call, a bot's 031 * button nor Copy; no notification is tapped, dismissed or cancelled; no 032 * action that would open an app on the phone. Before Android 12 nothing is 033 * offered at all: its actions do not say what {@link #refusal} needs. 034 */ 035final class Replies 036{ 037 /** Where an action was found: the notification's own actions, the car extender's invisible ones, the watch's. */ 038 static final int VISIBLE = 0; 039 static final int CAR = 1; 040 static final int WATCH = 2; 041 /** The places, in the order they are looked in. */ 042 private static final int[] PLACES = { VISIBLE, CAR, WATCH }; 043 /** Where AndroidX keeps a car extender's invisible actions in the extras (NotificationCompat.CarExtender, addInvisibleAction). */ 044 private static final String CAR_EXTENSIONS = "android.car.EXTENSIONS"; 045 private static final String CAR_INVISIBLE = "invisible_actions"; 046 047 private Replies() 048 { 049 } 050 051 /** 052 * One action of a notification as the choice reads it: plain data, made 053 * on the phone from a Notification.Action or a car extender's bundle 054 * ({@link #found}), by hand in android/jvm-checks/RepliesCheck.java. 055 */ 056 static final class Act 057 { 058 final int place; 059 /** What Android says it means (Notification.Action.getSemanticAction): 1 Reply, 2 Mark as read, 0 nothing said. */ 060 final int semantic; 061 /** Added by the system or its assistant from the notification's words (an address to open): never the app's own. */ 062 final boolean contextual; 063 /** How many of its inputs take free text (each with its key), and whether it has inputs at all. */ 064 final int freeText; 065 final boolean inputs; 066 /** Its PendingIntent: immutable, which would drop the words without a word from Android. */ 067 final boolean immutable; 068 /** Its PendingIntent starts an activity: it would open the app on the phone, maybe under the lock screen. */ 069 final boolean activity; 070 /** Its app asks that it be sent only from an unlocked phone (Notification.Action.isAuthenticationRequired). */ 071 final boolean authentication; 072 /** Its title, as the app wrote it: never read by the choice. */ 073 final String title; 074 075 Act(int place, int semantic, boolean contextual, int freeText, boolean inputs, boolean immutable, boolean activity, boolean authentication, String title) 076 { 077 this.place = place; 078 this.semantic = semantic; 079 this.contextual = contextual; 080 this.freeText = freeText; 081 this.inputs = inputs; 082 this.immutable = immutable; 083 this.activity = activity; 084 this.authentication = authentication; 085 this.title = title == null ? "" : title; 086 } 087 } 088 089 /** What a notification offers ({@link #choose}): its Reply and its Mark as read, each null when none. */ 090 static final class Choice 091 { 092 final Act reply; 093 final Act read; 094 095 Choice(Act reply, Act read) 096 { 097 this.reply = reply; 098 this.read = read; 099 } 100 } 101 102 /** 103 * A notification's Reply and Mark as read, by what Android says they 104 * mean, looked for in its own actions, then the car extender's, then the 105 * watch's: Reply, an action that says it is one (SEMANTIC_ACTION_REPLY) 106 * with an input that takes free text; Mark as read, one that says it is 107 * one (SEMANTIC_ACTION_MARK_AS_READ). The first place that has one 108 * decides; two in that place ("Reply" and "Reply privately" in a group, 109 * two Mark as read) would be a guess: none, never the first by order. 110 * When no action says it is a Reply, the one action whose input takes 111 * free text, said nothing of, in a notification Android takes as a 112 * conversation or the phone's SMS app's; two such would be a guess: none. 113 * Never one the system added (contextual), never one found by its title 114 * or its place. Pure: android/jvm-checks/RepliesCheck.java. 115 */ 116 static Choice choose(List<Act> acts, boolean conversation, boolean smsApp) 117 { 118 Act reply = null; 119 Act read = null; 120 boolean saysReply = false; 121 boolean replyFound = false; 122 boolean readFound = false; 123 for (int place : PLACES) { 124 Act placeReply = null; 125 Act placeRead = null; 126 int replies = 0; 127 int reads = 0; 128 for (Act act : acts) { 129 if (act.place != place || act.contextual) 130 continue; 131 saysReply |= act.semantic == Notification.Action.SEMANTIC_ACTION_REPLY; 132 if (act.semantic == Notification.Action.SEMANTIC_ACTION_REPLY && act.freeText > 0) { 133 placeReply = act; 134 replies++; 135 } 136 if (act.semantic == Notification.Action.SEMANTIC_ACTION_MARK_AS_READ) { 137 placeRead = act; 138 reads++; 139 } 140 } 141 if (!replyFound && replies > 0) { 142 reply = replies == 1 ? placeReply : null; 143 replyFound = true; 144 } 145 if (!readFound && reads > 0) { 146 read = reads == 1 ? placeRead : null; 147 readFound = true; 148 } 149 } 150 if (reply == null && !saysReply && (conversation || smsApp)) { 151 for (int place : PLACES) { 152 Act only = null; 153 int count = 0; 154 for (Act act : acts) { 155 if (act.place == place && !act.contextual && act.semantic == Notification.Action.SEMANTIC_ACTION_NONE && act.freeText > 0) { 156 only = act; 157 count++; 158 } 159 } 160 if (count == 1) 161 reply = only; 162 if (count > 0) 163 break; 164 } 165 } 166 return new Choice(reply, read); 167 } 168 169 /** 170 * Why an action may not be sent; "" when it may. "none": there is none; 171 * "activity": it would open an app on the phone; "closed": a Reply 172 * (`fill`) whose PendingIntent is immutable; "locked": its app asks for 173 * an unlocked phone and the phone is locked. Pure: 174 * android/jvm-checks/RepliesCheck.java. 175 */ 176 static String refusal(Act act, boolean fill, boolean locked) 177 { 178 if (act == null) 179 return "none"; 180 if (act.activity) 181 return "activity"; 182 if (fill && act.immutable) 183 return "closed"; 184 if (act.authentication && locked) 185 return "locked"; 186 return ""; 187 } 188 189 /** 190 * Which of the notifications listed a reply names: the one whose name is 191 * its (AppNotes.talkKey of its app and Android's key, as Rust's 192 * `replies::notice`), of this phone's own profile, never a work 193 * profile's; -1 for none. Pure: android/jvm-checks/RepliesCheck.java. 194 */ 195 static int live(String[] packages, String[] keys, boolean[] own, String notice) 196 { 197 for (int i = 0; i < packages.length && i < keys.length && i < own.length; i++) 198 if (own[i] && packages[i] != null && keys[i] != null && AppNotes.talkKey(packages[i], keys[i]).equals(notice)) 199 return i; 200 return -1; 201 } 202 203 // ---------------------------------------------------------------- on the phone 204 205 /** An action found on the phone: what the choice reads, and what sends it. */ 206 static final class Found 207 { 208 final Act act; 209 final PendingIntent intent; 210 /** Its first input that takes free text, the one the shade fills; null for none. */ 211 final RemoteInput input; 212 213 Found(Act act, PendingIntent intent, RemoteInput input) 214 { 215 this.act = act; 216 this.intent = intent; 217 this.input = input; 218 } 219 } 220 221 /** What the choice reads of the actions found. */ 222 static List<Act> acts(List<Found> found) 223 { 224 List<Act> out = new ArrayList<>(); 225 for (Found one : found) 226 out.add(one.act); 227 return out; 228 } 229 230 /** The action found for an act chosen; null for none. */ 231 static Found of(List<Found> found, Act act) 232 { 233 for (Found one : found) 234 if (one.act == act) 235 return one; 236 return null; 237 } 238 239 /** 240 * A notification's actions in the three places: its own, the car 241 * extender's invisible ones, the watch's. None before Android 12. 242 */ 243 @SuppressWarnings("deprecation") 244 static List<Found> found(Notification n) 245 { 246 List<Found> out = new ArrayList<>(); 247 if (n == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.S) 248 return out; 249 if (n.actions != null) 250 for (Notification.Action action : n.actions) 251 add(out, VISIBLE, action); 252 try { 253 car(n.extras, out); 254 } catch (RuntimeException e) { 255 // A car extender's extras that do not read: none of its actions. 256 } 257 try { 258 for (Notification.Action action : new Notification.WearableExtender(n).getActions()) 259 add(out, WATCH, action); 260 } catch (RuntimeException e) { 261 // A watch's extras that do not read: none of its actions. 262 } 263 return out; 264 } 265 266 /** One of a notification's own or a watch's actions. */ 267 private static void add(List<Found> out, int place, Notification.Action action) 268 { 269 if (action == null || action.actionIntent == null) 270 return; 271 RemoteInput first = null; 272 int free = 0; 273 RemoteInput[] inputs = action.getRemoteInputs(); 274 if (inputs != null) { 275 for (RemoteInput input : inputs) { 276 if (input != null && input.getAllowFreeFormInput() && input.getResultKey() != null && !input.getResultKey().isEmpty()) { 277 free++; 278 if (first == null) 279 first = input; 280 } 281 } 282 } 283 PendingIntent intent = action.actionIntent; 284 Act act = new Act(place, action.getSemanticAction(), action.isContextual(), free, inputs != null && inputs.length > 0, 285 intent.isImmutable(), intent.isActivity(), action.isAuthenticationRequired(), action.title == null ? "" : action.title.toString()); 286 out.add(new Found(act, intent, first)); 287 } 288 289 /** 290 * A car extender's invisible actions, as AndroidX keeps them: bundles 291 * "0", "1"… each with its PendingIntent ("actionIntent"), its meaning 292 * ("semanticAction"), its title and its inputs as bundles of their own 293 * ("remoteInputs": "resultKey", "allowFreeFormInput"); an input made again 294 * here from its key, which is all the shade's filling reads of it. 295 */ 296 @SuppressWarnings("deprecation") 297 private static void car(Bundle extras, List<Found> out) 298 { 299 Bundle car = extras == null ? null : extras.getBundle(CAR_EXTENSIONS); 300 Bundle invisible = car == null ? null : car.getBundle(CAR_INVISIBLE); 301 if (invisible == null) 302 return; 303 for (int i = 0; invisible.containsKey(Integer.toString(i)); i++) { 304 Bundle one = invisible.getBundle(Integer.toString(i)); 305 Parcelable intent = one == null ? null : one.getParcelable("actionIntent"); 306 if (!(intent instanceof PendingIntent)) 307 continue; 308 RemoteInput first = null; 309 int free = 0; 310 boolean any = false; 311 Parcelable[] inputs = one.getParcelableArray("remoteInputs"); 312 if (inputs != null) { 313 for (Parcelable input : inputs) { 314 if (!(input instanceof Bundle)) 315 continue; 316 any = true; 317 Bundle described = (Bundle) input; 318 String key = described.getString("resultKey"); 319 if (described.getBoolean("allowFreeFormInput", false) && key != null && !key.isEmpty()) { 320 free++; 321 if (first == null) 322 first = new RemoteInput.Builder(key).build(); 323 } 324 } 325 } 326 PendingIntent pending = (PendingIntent) intent; 327 CharSequence title = one.getCharSequence("title"); 328 Act act = new Act(CAR, one.getInt("semanticAction", Notification.Action.SEMANTIC_ACTION_NONE), false, free, any, 329 pending.isImmutable(), pending.isActivity(), false, title == null ? "" : title.toString()); 330 out.add(new Found(act, pending, first)); 331 } 332 } 333 334 /** 335 * What a notification offers a reply from your computers, for Rust 336 * (phonemsgs.rs, `Offer`): a Reply and a Mark as read there and 337 * sendable (the lock aside, which is looked at when one is sent). 338 */ 339 static JSONObject offer(Notification n, boolean conversation, boolean smsApp) throws JSONException 340 { 341 Choice choice = choose(acts(found(n)), conversation, smsApp); 342 return new JSONObject() 343 .put("reply", refusal(choice.reply, true, false).isEmpty()) 344 .put("read", refusal(choice.read, false, false).isEmpty()); 345 } 346 347 /** 348 * What another profile's notification (a work profile's) offers your 349 * computers: nothing, since the listener never answers one ({@link #live}). 350 */ 351 static JSONObject nothing() throws JSONException 352 { 353 return new JSONObject().put("reply", false).put("read", false); 354 } 355 356 /** 357 * Sent as the phone's shade sends a reply typed under a notification 358 * (SystemUI's RemoteInputViewController): the words under its first 359 * free-text input's key, in a fill-in intent sent to the foreground's 360 * receivers, marked typed by a person (SOURCE_FREE_FORM_INPUT); a Mark 361 * as read (`fill` false), with no fill-in. No start of an activity is 362 * asked for. Throws when the intent is spent or cancelled: its 363 * notification is gone. 364 */ 365 static void send(Context context, Found found, String words, boolean fill) throws PendingIntent.CanceledException 366 { 367 if (!fill) { 368 found.intent.send(context, 0, null, null, null); 369 return; 370 } 371 Intent filled = new Intent().addFlags(Intent.FLAG_RECEIVER_FOREGROUND); 372 Bundle results = new Bundle(); 373 results.putCharSequence(found.input.getResultKey(), words); 374 RemoteInput.addResultsToIntent(new RemoteInput[] { found.input }, filled, results); 375 RemoteInput.setResultsSource(filled, RemoteInput.SOURCE_FREE_FORM_INPUT); 376 found.intent.send(context, 0, filled, null, null); 377 } 378}