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}