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.content.BroadcastReceiver;
008import android.content.ContentResolver;
009import android.content.Context;
010import android.content.Intent;
011import android.content.IntentFilter;
012import android.content.pm.PackageManager;
013import android.database.ContentObserver;
014import android.database.Cursor;
015import android.net.Uri;
016import android.os.BatteryManager;
017import android.os.Build;
018import android.os.Handler;
019import android.os.Looper;
020import android.provider.Settings;
021import android.provider.Telephony;
022import android.telephony.SubscriptionInfo;
023import android.telephony.SubscriptionManager;
024import android.util.Log;
025import java.io.File;
026import java.io.FileOutputStream;
027import java.io.IOException;
028import java.nio.charset.StandardCharsets;
029import java.nio.file.Files;
030import java.util.ArrayList;
031import java.util.List;
032import java.util.concurrent.atomic.AtomicBoolean;
033import org.json.JSONArray;
034import org.json.JSONException;
035import org.json.JSONObject;
036
037/**
038 * Texts read on the phone for your computers (docs/texts.md; Rust's side:
039 * crates/sioul-app/src/texts.rs), without Sioul being the phone's SMS app:
040 * the SMS app stays the record. With READ_SMS, Android shows another app its
041 * inbox and its sent box only (the provider's restricted views): never the
042 * drafts, the failed or queued texts, nor a multimedia message still to
043 * download. Java reads, and hands Rust plain JSON; Rust keys the numbers,
044 * seals the lines, and decides what travels. Java never writes a shared file.
045 *
046 * Asked through {@link StepService#call} ("texts-…", from either process): the
047 * permissions and the SIMs, Android's question for the permissions
048 * (TextsAsk), the rows Rust's reader asks for by their ids (the whole
049 * history, every column, multimedia messages and their parts), a part's
050 * content, every row there is with its date, the texts Sioul sent lately
051 * (to find one after a crash),
052 * and a text to send (TextSend), whether the phone's texts changed since the
053 * last look and whether it charges (`changes`). Heard: a new text, which
054 * brings a step soon. The background service watches Android's provider of
055 * texts (`watch`): the import asks Android only when they changed, and a
056 * message received (a multimedia one too, once downloaded) brings a step
057 * soon, as a new text does.
058 */
059public final class Texts
060{
061    static final String TAG = DoseAlarms.TAG;
062    /** Sioul's private texts folder in its state (Rust's `texts::private_folder`): Java's own files there. */
063    static final String FOLDER = "state/sioul/texts";
064    /** Android's provider: a text sent; received. */
065    static final int SENT_BOX = 2;
066
067    /** The phone's texts changed since Rust last asked with "clear" (`changes`): set at first, so that the first step looks. */
068    private static final AtomicBoolean changed = new AtomicBoolean(true);
069    /** Android's provider of texts watched in this process (the background service's, or the window's); null: not watched here. */
070    private static ContentObserver watching;
071    /** The newest message received, by the provider's id, of each table (SMS, MMS), as the watch last saw it; -1: not read yet. */
072    private static long newestSms = -1;
073    private static long newestMms = -1;
074    /** How long after the provider's last word the watch looks for a message received: a burst of changes, one look. */
075    private static final long LOOK_MS = 1_000;
076    /** Where that look runs (the process's main thread, which the service leaves free), and the look itself; null: not watched. */
077    private static Handler looks;
078    private static Runnable look;
079
080    private Texts()
081    {
082    }
083
084    /**
085     * Android's provider of texts watched in this process (the background
086     * service's), once READ_SMS is given: a text received, sent, deleted or
087     * read marks them changed, read at the next step; a message received (its
088     * newest id grown, a multimedia one once downloaded) brings a step soon,
089     * as SMS_RECEIVED does (`Heard`), so that it reaches your computers
090     * within the minute. The look for it comes a second after the provider's
091     * last word: one message tells it several times (its text, its row, its
092     * conversation), one look. Asked again at each step until it holds.
093     */
094    static synchronized void watch(Context context)
095    {
096        watch(context, true);
097    }
098
099    /**
100     * The same, `steps` saying whether a message received brings a step (the
101     * background service's process); in the window's, which exchanges each
102     * minute by itself, the watch only marks the texts changed, so that its
103     * exchanges ask Android for every row only after a change (the review of
104     * 16 October 2026, LOW 3: unwatched, it listed every row once a minute
105     * while on screen).
106     */
107    static synchronized void watch(Context context, boolean steps)
108    {
109        if (watching != null || !allowed(context, Manifest.permission.READ_SMS))
110            return;
111        final Context app = context.getApplicationContext();
112        final Handler later = steps ? new Handler(Looper.getMainLooper()) : null;
113        final Runnable once = steps ? () -> {
114            if (arrived(app))
115                StepService.soon(app);
116        } : null;
117        ContentObserver observer = new ContentObserver(null) {
118            @Override
119            public void onChange(boolean self)
120            {
121                changed.set(true);
122                if (later != null) {
123                    later.removeCallbacks(once);
124                    later.postDelayed(once, LOOK_MS);
125                }
126            }
127        };
128        try {
129            ContentResolver resolver = context.getContentResolver();
130            for (Uri uri : new Uri[] { Telephony.Sms.CONTENT_URI, Telephony.Mms.CONTENT_URI, Telephony.MmsSms.CONTENT_URI })
131                resolver.registerContentObserver(uri, true, observer);
132            watching = observer;
133            looks = later;
134            look = once;
135            changed.set(true);
136            // The messages there now: none of them brings a step.
137            if (steps)
138                arrived(app);
139        } catch (RuntimeException e) {
140            context.getContentResolver().unregisterContentObserver(observer);
141            Log.w(TAG, "Texts: the texts cannot be watched: " + e.getClass().getSimpleName());
142        }
143    }
144
145    /**
146     * Whether a message was received since the watch last looked: the newest
147     * id of either inbox grew (`grew`); the newest ones kept. Nothing of them
148     * is read but their ids.
149     */
150    static boolean arrived(Context context)
151    {
152        long sms = newest(context, Telephony.Sms.Inbox.CONTENT_URI);
153        long mms = newest(context, Telephony.Mms.Inbox.CONTENT_URI);
154        synchronized (Texts.class) {
155            boolean grown = grew(newestSms, sms) || grew(newestMms, mms);
156            newestSms = Math.max(newestSms, sms);
157            newestMms = Math.max(newestMms, mms);
158            return grown;
159        }
160    }
161
162    /**
163     * Whether an inbox's newest id grew: read before (not -1), larger now (a
164     * box that could not be read now says -1: no). Pure:
165     * android/jvm-checks/StepsCheck.java.
166     */
167    static boolean grew(long before, long now)
168    {
169        return before >= 0 && now > before;
170    }
171
172    /**
173     * The newest id in an inbox, 0 when it is empty, -1 when it cannot be
174     * read (`newestBy`).
175     */
176    private static long newest(Context context, Uri box)
177    {
178        return newestBy(order -> {
179            try (Cursor cursor = context.getContentResolver().query(box, new String[] { "_id" }, null, null, order)) {
180                return cursor != null && cursor.moveToFirst() ? cursor.getLong(0) : 0;
181            }
182        });
183    }
184
185    /** An inbox's newest id asked in an order (`NEWEST`); a refusal throws. */
186    interface Look
187    {
188        long newest(String order);
189    }
190
191    /**
192     * How the newest row is asked for: the limit in the sort, since Android's
193     * own SMS and MMS providers pass no ContentResolver.QUERY_ARG_LIMIT on and
194     * would give every id each time; then, from a provider that refuses a
195     * limit there, the plain sort, its ids in that order and only the first
196     * read.
197     */
198    static final String[] NEWEST = { "_id DESC LIMIT 1", "_id DESC" };
199
200    /**
201     * The newest id as `look` gives it, asked in each order of NEWEST until
202     * one is taken; -1 when none is (the inbox cannot be read). Pure:
203     * android/jvm-checks/StepsCheck.java.
204     */
205    static long newestBy(Look look)
206    {
207        for (String order : NEWEST) {
208            try {
209                return look.newest(order);
210            } catch (RuntimeException e) {
211                // Refused: the next order.
212            }
213        }
214        return -1;
215    }
216
217    /** No longer watched (the service ending). */
218    static synchronized void unwatch(Context context)
219    {
220        if (watching != null)
221            context.getContentResolver().unregisterContentObserver(watching);
222        if (looks != null && look != null)
223            looks.removeCallbacks(look);
224        watching = null;
225        looks = null;
226        look = null;
227        changed.set(true);
228    }
229
230    /** A new text heard (Heard): changed, whatever the provider said yet. */
231    static void touched()
232    {
233        changed.set(true);
234    }
235
236    /**
237     * Whether the texts changed since the last look: always when not watched
238     * here (READ_SMS not given yet); else as marked,
239     * the mark cleared when `clear`. Pure: android/jvm-checks/StepsCheck.java.
240     */
241    static boolean changedSince(boolean watched, AtomicBoolean mark, boolean clear)
242    {
243        if (!watched)
244            return true;
245        return clear ? mark.getAndSet(false) : mark.get();
246    }
247
248    /** Rust's "texts-changes": {changed (`changedSince`), plugged (the phone on its charger)}. */
249    static JSONObject changes(Context context, boolean clear) throws JSONException
250    {
251        boolean watched;
252        synchronized (Texts.class) {
253            watched = watching != null;
254        }
255        return new JSONObject().put("changed", changedSince(watched, changed, clear)).put("plugged", plugged(context));
256    }
257
258    /** The phone on its charger, by Android's last word on the battery (a sticky broadcast: no receiver kept). */
259    static boolean plugged(Context context)
260    {
261        try {
262            Intent battery = context.registerReceiver(null, new IntentFilter(Intent.ACTION_BATTERY_CHANGED));
263            return battery != null && battery.getIntExtra(BatteryManager.EXTRA_PLUGGED, 0) != 0;
264        } catch (RuntimeException e) {
265            return false;
266        }
267    }
268
269    /** A row of a cursor, or of a check's stand-in: its values by column. */
270    interface Row
271    {
272        String text(String column);
273
274        long number(String column);
275    }
276
277    /** A cursor's current row. */
278    private static Row of(Cursor cursor)
279    {
280        return new Row() {
281            @Override
282            public String text(String column)
283            {
284                int index = cursor.getColumnIndex(column);
285                if (index < 0 || cursor.isNull(index))
286                    return "";
287                String value = cursor.getString(index);
288                return value == null ? "" : value;
289            }
290
291            @Override
292            public long number(String column)
293            {
294                int index = cursor.getColumnIndex(column);
295                return index < 0 || cursor.isNull(index) ? 0 : cursor.getLong(index);
296            }
297        };
298    }
299
300    // ---------------------------------------------------------------- Rust's questions
301
302    /** Rust's question ({@link StepService#call}): its answer in JSON, or null. */
303    static String call(Context context, String verb, JSONObject asked) throws JSONException
304    {
305        ContentResolver resolver = context.getContentResolver();
306        switch (verb) {
307        case "texts-state":
308            return state(context).toString();
309        case "texts-ask":
310            ask(context);
311            return "true";
312        case "texts-read":
313            if (!allowed(context, Manifest.permission.READ_SMS))
314                return new JSONObject().put("allowed", false).toString();
315            return read(resolver, asked.optLong("sms", 0), asked.optLong("mms", 0), asked.optInt("limit", 500), asked.optJSONArray("sms_ids"), asked.optJSONArray("mms_ids")).put("allowed", true).toString();
316        case "texts-ids":
317            if (!allowed(context, Manifest.permission.READ_SMS))
318                return new JSONObject().put("allowed", false).toString();
319            return ids(resolver, asked.optBoolean("measure", false)).put("allowed", true).toString();
320        case "texts-part":
321            if (!allowed(context, Manifest.permission.READ_SMS))
322                return new JSONObject().put("allowed", false).toString();
323            return part(resolver, asked.optLong("id", 0), asked.optString("target", "")).put("allowed", true).toString();
324        case "texts-find":
325            if (!allowed(context, Manifest.permission.READ_SMS))
326                return new JSONObject().put("allowed", false).toString();
327            return find(resolver, context.getPackageName(), asked.optLong("since", 0)).put("allowed", true).toString();
328        case "texts-send":
329            return TextSend.send(context, asked.optString("key", ""), asked.optString("to", ""), asked.optString("body", ""), asked.optInt("sub", -1)).toString();
330        case "texts-send-mms":
331            return TextSend.sendMms(context, asked.optString("key", ""), asked.optInt("sub", -1), asked.optString("path", "")).toString();
332        case "texts-limits":
333            return limits(context, asked.has("sub") ? asked.optInt("sub", -1) : null).toString();
334        case "texts-find-mms":
335            if (!allowed(context, Manifest.permission.READ_SMS))
336                return new JSONObject().put("allowed", false).toString();
337            return findMms(resolver, context.getPackageName(), asked.optString("key", "")).put("allowed", true).toString();
338        case "texts-changes":
339            // Watched in this process too (the window's, without steps of its own): a step asks Android only after a change.
340            watch(context, StepService.inThisProcess());
341            return changes(context, asked.optBoolean("clear", true)).toString();
342        case "texts-info":
343            return info(context) ? "true" : "false";
344        default:
345            return null;
346        }
347    }
348
349    static boolean allowed(Context context, String permission)
350    {
351        return context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED;
352    }
353
354    /** What Android allows Sioul: each permission, the phone's SMS app, its SIMs (with READ_PHONE_STATE: their names). */
355    static JSONObject state(Context context) throws JSONException
356    {
357        JSONObject out = new JSONObject();
358        out.put("api", Build.VERSION.SDK_INT);
359        out.put("read", allowed(context, Manifest.permission.READ_SMS));
360        out.put("receive", allowed(context, Manifest.permission.RECEIVE_SMS));
361        out.put("send", allowed(context, Manifest.permission.SEND_SMS));
362        boolean phoneState = allowed(context, Manifest.permission.READ_PHONE_STATE);
363        out.put("phone_state", phoneState);
364        String sms = null;
365        try {
366            sms = Telephony.Sms.getDefaultSmsPackage(context);
367        } catch (RuntimeException e) {
368            // No telephony.
369        }
370        out.put("sms_app", sms == null ? "" : sms);
371        // The texts' question asked here once at least, and its last answer at once, nothing given: none was shown.
372        out.put("asked", asked(context) != null);
373        out.put("at_once", askedAtOnce(context));
374        JSONArray sims = new JSONArray();
375        if (phoneState) {
376            try {
377                SubscriptionManager subscriptions = context.getSystemService(SubscriptionManager.class);
378                List<SubscriptionInfo> active = subscriptions == null ? null : subscriptions.getActiveSubscriptionInfoList();
379                if (active != null)
380                    for (SubscriptionInfo info : active)
381                        sims.put(new JSONObject().put("id", info.getSubscriptionId()).put("slot", info.getSimSlotIndex())
382                                     .put("name", info.getDisplayName() == null ? "" : info.getDisplayName().toString()));
383            } catch (SecurityException e) {
384                // Taken back meanwhile.
385            }
386        }
387        out.put("sims", sims);
388        return out;
389    }
390
391    // ---------------------------------------------------------------- what Android withholds
392
393    /**
394     * How quickly Android must answer the question, giving nothing, for no
395     * question to have been shown: a person reads one longer (ms).
396     */
397    static final long AT_ONCE_MS = 700;
398    /** Java's own file, in the private texts folder: how Android answered the last question (TextsAsk). */
399    static final String ASKED = "asked.json";
400
401    /*
402     * Whether Android keeps "restricted settings" from Sioul (Android 13 and
403     * later, an app installed from a file) cannot be read by an app: the app
404     * op that says it, ACCESS_RESTRICTED_SETTINGS, is restrict-read on every
405     * release that has it (MANAGE_APPOPS and the like, which no app holds),
406     * and Android 15's enhanced confirmation asks a permission of its own.
407     * Sioul says the steps that hold either way instead (docs/android.md,
408     * "What Android withholds").
409     */
410
411    /**
412     * Whether Android answered the texts' question at once, giving none of
413     * the three: it showed none (Android 15's restricted settings after the
414     * first time, or "Don't allow" chosen twice before). `took` is -1 when
415     * not measured. Pure: android/jvm-checks/TextsCheck.java.
416     */
417    static boolean atOnce(int given, long took)
418    {
419        return given == 0 && took >= 0 && took < AT_ONCE_MS;
420    }
421
422    /** How Android answered the question (TextsAsk), kept in Java's own file for `state`: how many of how many, how fast. */
423    static void remember(Context context, int given, int of, long took)
424    {
425        File folder = new File(context.getFilesDir(), FOLDER);
426        folder.mkdirs();
427        try (FileOutputStream out = new FileOutputStream(new File(folder, ASKED))) {
428            out.write(new JSONObject().put("at", System.currentTimeMillis()).put("given", given).put("of", of).put("took", took).toString().getBytes(StandardCharsets.UTF_8));
429        } catch (IOException | JSONException e) {
430            Log.w(TAG, "Texts: the answer could not be kept: " + e.getClass().getSimpleName());
431        }
432    }
433
434    /** How Android answered the texts' last question (`remember`); null when none was asked here. */
435    static JSONObject asked(Context context)
436    {
437        try {
438            return new JSONObject(new String(Files.readAllBytes(new File(new File(context.getFilesDir(), FOLDER), ASKED).toPath()), StandardCharsets.UTF_8));
439        } catch (IOException | JSONException | RuntimeException e) {
440            return null;
441        }
442    }
443
444    /** Whether the last question was answered at once, nothing given (`atOnce`); false when none was asked. */
445    static boolean askedAtOnce(Context context)
446    {
447        JSONObject asked = asked(context);
448        return asked != null && atOnce(asked.optInt("given", 0), asked.optLong("took", -1));
449    }
450
451    /** Android's App info for Sioul, where ⋮ ▸ Allow restricted settings and the permissions are. */
452    static boolean info(Context context)
453    {
454        try {
455            context.startActivity(new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:" + context.getPackageName())).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
456            return true;
457        } catch (RuntimeException e) {
458            Log.w(TAG, "Texts: App info could not be opened: " + e.getClass().getSimpleName());
459            return false;
460        }
461    }
462
463    /** Android's question for the texts' permissions: an activity never seen (TextsAsk), from Sioul's window. */
464    static void ask(Context context)
465    {
466        try {
467            context.startActivity(new Intent(context, TextsAsk.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK));
468        } catch (RuntimeException e) {
469            Log.w(TAG, "Texts: Android's question could not be asked: " + e);
470        }
471    }
472
473    // ---------------------------------------------------------------- reading
474
475    /** Columns never kept: where the provider keeps a part's file on this phone. */
476    static final String LOCAL_PATH = "_data";
477
478    /** A row's every column, as the provider gives it (a part's file's place on the phone aside). */
479    static JSONObject copy(Cursor cursor) throws JSONException
480    {
481        JSONObject out = new JSONObject();
482        for (String column : cursor.getColumnNames()) {
483            int index = cursor.getColumnIndex(column);
484            if (index < 0 || LOCAL_PATH.equals(column))
485                continue;
486            switch (cursor.getType(index)) {
487            case Cursor.FIELD_TYPE_INTEGER:
488                out.put(column, cursor.getLong(index));
489                break;
490            case Cursor.FIELD_TYPE_FLOAT:
491                out.put(column, cursor.getDouble(index));
492                break;
493            case Cursor.FIELD_TYPE_STRING:
494                out.put(column, cursor.getString(index));
495                break;
496            default:
497                // Null and blobs: not kept (no provider column a restore needs holds a blob).
498                break;
499            }
500        }
501        return out;
502    }
503
504    /** A text of the SMS provider, as Rust reads it (crates/sioul-app/src/texts.rs, `Raw`): every column. */
505    static JSONObject sms(JSONObject row) throws JSONException
506    {
507        return new JSONObject().put("kind", "sms").put("fields", row);
508    }
509
510    /**
511     * A multimedia message, as Rust reads it: every column of its row, of its
512     * addresses and of its parts (each part's size from Android, its file's
513     * place on the phone left out), so that a later restore loses nothing.
514     */
515    static JSONObject mms(JSONObject row, List<JSONObject> addresses, List<JSONObject> parts) throws JSONException
516    {
517        JSONArray addrs = new JSONArray();
518        for (JSONObject a : addresses)
519            addrs.put(a);
520        JSONArray all = new JSONArray();
521        for (JSONObject p : parts) {
522            p.remove(LOCAL_PATH);
523            all.put(p);
524        }
525        return new JSONObject().put("kind", "mms").put("fields", row).put("addrs", addrs).put("parts", all);
526    }
527
528    /** Whether a part's content travels as a file: anything but words and the SMIL that lays them out. */
529    static boolean media(String type)
530    {
531        return type != null && !type.startsWith("text/plain") && !"application/smil".equals(type);
532    }
533
534    /** A part's size in bytes, as Android opens it; -1 when it does not open (not downloaded). */
535    static long size(ContentResolver resolver, long part)
536    {
537        try (android.content.res.AssetFileDescriptor file = resolver.openAssetFileDescriptor(Uri.parse("content://mms/part/" + part), "r")) {
538            return file == null ? -1 : file.getLength();
539        } catch (java.io.IOException | RuntimeException e) {
540            return -1;
541        }
542    }
543
544    /**
545     * Each conversation's people, by its thread: Android's own list of each
546     * thread's recipients (the person themselves never among them), so that a
547     * group reads as a group.
548     */
549    static JSONObject threads(ContentResolver resolver) throws JSONException
550    {
551        JSONObject addresses = new JSONObject();
552        try (Cursor cursor = resolver.query(Uri.parse("content://mms-sms/canonical-addresses"), null, null, null, null)) {
553            while (cursor != null && cursor.moveToNext()) {
554                Row row = of(cursor);
555                addresses.put(String.valueOf(row.number("_id")), row.text("address"));
556            }
557        } catch (RuntimeException e) {
558            Log.w(TAG, "Texts: the conversations' people could not be read: " + e.getClass().getSimpleName());
559        }
560        JSONObject threads = new JSONObject();
561        try (Cursor cursor = resolver.query(Uri.parse("content://mms-sms/conversations?simple=true"), new String[] { "_id", "recipient_ids" }, null, null, null)) {
562            while (cursor != null && cursor.moveToNext()) {
563                Row row = of(cursor);
564                JSONArray people = new JSONArray();
565                for (String id : row.text("recipient_ids").trim().split("\\s+"))
566                    if (!id.isEmpty() && addresses.has(id))
567                        people.put(addresses.getString(id));
568                threads.put(String.valueOf(row.number("_id")), people);
569            }
570        } catch (RuntimeException e) {
571            Log.w(TAG, "Texts: the conversations could not be read: " + e.getClass().getSimpleName());
572        }
573        return threads;
574    }
575
576    /**
577     * Which rows a read takes: given `ids` (the rows Rust's reader asks for:
578     * those its log lacks, by their ids), those rows only; else those after
579     * the mark (no reader asks so since its version 4, 16 October 2026).
580     */
581    static String rows(JSONArray ids)
582    {
583        if (ids == null)
584            return "_id > ?";
585        StringBuilder in = new StringBuilder("_id IN (");
586        for (int i = 0; i < ids.length(); i++)
587            in.append(i == 0 ? "" : ",").append(ids.optLong(i, -1));
588        return in.append(ids.length() == 0 ? "-1)" : ")").toString();
589    }
590
591    /**
592     * The rows `smsIds` and `mmsIds` name (else those after the marks `sms`,
593     * `mms`: `rows`); `limit` at most, the plain texts first, by id, every
594     * column, the date among them (Rust tells a row by its id and its date);
595     * each conversation's people; and the new marks.
596     */
597    static JSONObject read(ContentResolver resolver, long smsMark, long mmsMark, int limit, JSONArray smsIds, JSONArray mmsIds) throws JSONException
598    {
599        JSONArray texts = new JSONArray();
600        long lastSms = smsMark;
601        int smsCount = 0;
602        try (Cursor cursor = resolver.query(Telephony.Sms.CONTENT_URI, null, rows(smsIds), smsIds == null ? new String[] { String.valueOf(smsMark) } : null, "_id ASC")) {
603            while (cursor != null && cursor.moveToNext() && smsCount < limit) {
604                JSONObject row = copy(cursor);
605                lastSms = Math.max(lastSms, row.optLong("_id"));
606                texts.put(sms(row));
607                smsCount++;
608            }
609        } catch (RuntimeException e) {
610            Log.w(TAG, "Texts: the texts could not be read: " + e.getClass().getSimpleName());
611        }
612        long lastMms = mmsMark;
613        int mmsCount = 0;
614        try (Cursor cursor = resolver.query(Telephony.Mms.CONTENT_URI, null, rows(mmsIds), mmsIds == null ? new String[] { String.valueOf(mmsMark) } : null, "_id ASC")) {
615            while (cursor != null && smsCount + mmsCount < limit && cursor.moveToNext()) {
616                JSONObject row = copy(cursor);
617                long id = row.optLong("_id");
618                lastMms = Math.max(lastMms, id);
619                List<JSONObject> parts = copies(resolver, Uri.parse("content://mms/part"), "mid = ?", new String[] { String.valueOf(id) });
620                for (JSONObject part : parts)
621                    if (media(part.optString("ct", "")))
622                        part.put("size", size(resolver, part.optLong("_id")));
623                texts.put(mms(row, copies(resolver, Uri.parse("content://mms/" + id + "/addr"), null, null), parts));
624                mmsCount++;
625            }
626        } catch (RuntimeException e) {
627            Log.w(TAG, "Texts: the multimedia messages could not be read: " + e.getClass().getSimpleName());
628        }
629        // The conversations' people only when there is something to read: most steps find nothing new.
630        JSONObject threads = texts.length() > 0 ? threads(resolver) : new JSONObject();
631        return new JSONObject().put("texts", texts).put("threads", threads).put("sms", lastSms).put("mms", lastMms);
632    }
633
634    /** Every row of a query, every column copied (the cursor closed after). */
635    private static List<JSONObject> copies(ContentResolver resolver, Uri uri, String selection, String[] arguments)
636    {
637        List<JSONObject> out = new ArrayList<>();
638        try (Cursor cursor = resolver.query(uri, null, selection, arguments, null)) {
639            while (cursor != null && cursor.moveToNext())
640                out.add(copy(cursor));
641        } catch (JSONException | RuntimeException e) {
642            Log.w(TAG, "Texts: a message's parts could not be read: " + e.getClass().getSimpleName());
643        }
644        return out;
645    }
646
647    /**
648     * Every row there is now, each id with its own date ("sms" with "sms_at",
649     * "mms" with "mms_at": the plain texts' table gives a deleted newest
650     * text's id to the next one, and the date tells the two apart; Rust's
651     * `texts::Row`) and, when `measure`, how much media the multimedia
652     * messages hold: the rows to read, the texts deleted on the phone, and
653     * the sizes the settings say. A table that could not be listed whole is
654     * left out: Rust then reads nothing and marks nothing deleted, never
655     * taking "not known" for "none there".
656     */
657    static JSONObject ids(ContentResolver resolver, boolean measure) throws JSONException
658    {
659        JSONObject out = new JSONObject();
660        listed(resolver, Telephony.Sms.CONTENT_URI, "sms", out);
661        listed(resolver, Telephony.Mms.CONTENT_URI, "mms", out);
662        long media = 0;
663        // Measured only on a whole listing: a look that cannot list waits, and measures nothing meanwhile.
664        if (measure && out.has("sms") && out.has("mms")) {
665            try (Cursor cursor = resolver.query(Uri.parse("content://mms/part"), new String[] { "_id", "ct" }, null, null, null)) {
666                while (cursor != null && cursor.moveToNext())
667                    if (media(cursor.getString(1)))
668                        media += Math.max(0, size(resolver, cursor.getLong(0)));
669            } catch (RuntimeException e) {
670                Log.w(TAG, "Texts: the media could not be measured: " + e.getClass().getSimpleName());
671            }
672        }
673        return out.put("media", media);
674    }
675
676    /** A table's ids and dates (`ids`), put in `out` under `name` and `name_at` only when listed whole. */
677    private static void listed(ContentResolver resolver, Uri table, String name, JSONObject out) throws JSONException
678    {
679        JSONArray ids = new JSONArray();
680        JSONArray dates = new JSONArray();
681        try (Cursor cursor = resolver.query(table, new String[] { "_id", "date" }, null, null, null)) {
682            if (cursor == null)
683                return;
684            while (cursor.moveToNext()) {
685                ids.put(cursor.getLong(0));
686                dates.put(cursor.getLong(1));
687            }
688        } catch (RuntimeException e) {
689            Log.w(TAG, "Texts: the " + name + " rows could not be listed: " + e.getClass().getSimpleName());
690            return;
691        }
692        out.put(name, ids).put(name + "_at", dates);
693    }
694
695    /** A part's content copied to `target` (Sioul's own folder), for Rust to seal and share: {copied: bytes}, or -1. */
696    static JSONObject part(ContentResolver resolver, long part, String target) throws JSONException
697    {
698        long copied = -1;
699        java.io.File file = new java.io.File(target);
700        java.io.File parent = file.getParentFile();
701        if (parent != null)
702            parent.mkdirs();
703        try (java.io.InputStream in = resolver.openInputStream(Uri.parse("content://mms/part/" + part));
704             java.io.OutputStream out = new java.io.FileOutputStream(file)) {
705            if (in != null) {
706                byte[] buffer = new byte[64 * 1024];
707                long total = 0;
708                int read;
709                while ((read = in.read(buffer)) > 0) {
710                    out.write(buffer, 0, read);
711                    total += read;
712                }
713                copied = total;
714            }
715        } catch (java.io.IOException | RuntimeException e) {
716            copied = -1;
717        }
718        if (copied < 0)
719            file.delete();
720        return new JSONObject().put("copied", copied);
721    }
722
723    /**
724     * The texts Sioul sent since `since` (ms), as Android filed them in the
725     * sent box with Sioul as their creator: after a crash between a claim and
726     * Android's word, Rust looks for the one it claimed.
727     */
728    static JSONObject find(ContentResolver resolver, String own, long since) throws JSONException
729    {
730        JSONArray texts = new JSONArray();
731        try (Cursor cursor = resolver.query(Telephony.Sms.CONTENT_URI, null, "type = ? AND date >= ?", new String[] { String.valueOf(SENT_BOX), String.valueOf(since) }, "date ASC")) {
732            while (cursor != null && cursor.moveToNext()) {
733                JSONObject row = copy(cursor);
734                if (own.equals(row.optString(Telephony.TextBasedSmsColumns.CREATOR, "")))
735                    texts.put(sms(row));
736            }
737        } catch (RuntimeException e) {
738            Log.w(TAG, "Texts: the texts sent could not be read: " + e);
739        }
740        return new JSONObject().put("texts", texts);
741    }
742
743    // ---------------------------------------------------------------- multimedia messages
744
745    /**
746     * What each SIM's carrier lets Sioul send (Android's carrier
747     * configuration, {@code SmsManager.getCarrierConfigValues}, which asks no
748     * permission): that SIM's alone ({@code only}; -1 the phone's default),
749     * else each active SIM's (READ_PHONE_STATE lists them), else the
750     * default's. Numbers only, never the carrier's name.
751     */
752    static JSONObject limits(Context context, Integer only) throws JSONException
753    {
754        List<Integer> subs = new ArrayList<>();
755        if (only != null) {
756            subs.add(only);
757        } else {
758            if (allowed(context, Manifest.permission.READ_PHONE_STATE)) {
759                try {
760                    SubscriptionManager subscriptions = context.getSystemService(SubscriptionManager.class);
761                    List<SubscriptionInfo> active = subscriptions == null ? null : subscriptions.getActiveSubscriptionInfoList();
762                    if (active != null)
763                        for (SubscriptionInfo info : active)
764                            subs.add(info.getSubscriptionId());
765                } catch (SecurityException e) {
766                    // Taken back meanwhile.
767                }
768            }
769            if (subs.isEmpty())
770                subs.add(-1);
771        }
772        JSONArray out = new JSONArray();
773        for (int sub : subs) {
774            try {
775                android.os.Bundle values = TextSend.manager(context, sub).getCarrierConfigValues();
776                if (values != null)
777                    out.put(limitsLine(sub, values.getInt(android.telephony.SmsManager.MMS_CONFIG_MAX_MESSAGE_SIZE, 0), values.getInt(android.telephony.SmsManager.MMS_CONFIG_MAX_IMAGE_WIDTH, 0),
778                                       values.getInt(android.telephony.SmsManager.MMS_CONFIG_MAX_IMAGE_HEIGHT, 0), values.getInt(android.telephony.SmsManager.MMS_CONFIG_RECIPIENT_LIMIT, Integer.MAX_VALUE),
779                                       values.getBoolean(android.telephony.SmsManager.MMS_CONFIG_GROUP_MMS_ENABLED, true), values.getBoolean(android.telephony.SmsManager.MMS_CONFIG_MMS_ENABLED, true)));
780            } catch (RuntimeException e) {
781                Log.w(TAG, "Texts: a SIM's carrier values could not be read: " + e.getClass().getSimpleName());
782            }
783        }
784        return new JSONObject().put("limits", out);
785    }
786
787    /**
788     * One SIM's carrier values as Rust reads them ({@code texts::Limits}):
789     * none below zero (a carrier's "no limit" said as the largest count).
790     * Pure: android/jvm-checks/TextsCheck.java.
791     */
792    static JSONObject limitsLine(int sub, int max, int width, int height, int recipients, boolean group, boolean mms) throws JSONException
793    {
794        return new JSONObject().put("sub", sub).put("max", Math.max(max, 0)).put("width", Math.max(width, 0)).put("height", Math.max(height, 0))
795            .put("recipients", recipients < 0 ? Integer.MAX_VALUE : recipients).put("group", group).put("mms", mms);
796    }
797
798    /**
799     * Sioul's multimedia message by its key: the rows Android filed with that
800     * transaction id (the message Rust built carries it) and Sioul as their
801     * creator, each its id, its date (seconds) and its box (2: sent).
802     */
803    static JSONObject findMms(ContentResolver resolver, String own, String key) throws JSONException
804    {
805        JSONArray found = new JSONArray();
806        if (!key.isEmpty()) {
807            // RESPONSE_STATUS: the carrier's status Android read in its answer (none when it read none).
808            String[] columns = { Telephony.Mms._ID, Telephony.Mms.DATE, Telephony.Mms.MESSAGE_BOX, Telephony.Mms.CREATOR, Telephony.Mms.RESPONSE_STATUS };
809            try (Cursor cursor = resolver.query(Telephony.Mms.CONTENT_URI, columns, Telephony.Mms.TRANSACTION_ID + " = ?", new String[] { key }, null)) {
810                // No cursor: the provider did not answer, which is no "none there".
811                if (cursor == null)
812                    return new JSONObject().put("found", found).put("error", true);
813                while (cursor.moveToNext()) {
814                    Row row = of(cursor);
815                    if (own.equals(row.text(Telephony.Mms.CREATOR)))
816                        found.put(new JSONObject().put("id", row.number(Telephony.Mms._ID)).put("date", row.number(Telephony.Mms.DATE)).put("box", row.number(Telephony.Mms.MESSAGE_BOX))
817                            .put("status", row.number(Telephony.Mms.RESPONSE_STATUS)));
818                }
819            } catch (RuntimeException e) {
820                // A look that failed says so: Rust looks again, never reads it as "not there".
821                Log.w(TAG, "Texts: the multimedia messages sent could not be read: " + e);
822                return new JSONObject().put("found", new JSONArray()).put("error", true);
823            }
824        }
825        return new JSONObject().put("found", found);
826    }
827
828    // ---------------------------------------------------------------- heard
829
830    /**
831     * A new text, after the phone's SMS app had it (SMS_RECEIVED, sent by
832     * the system alone: its permission): a step soon, in which Rust reads it
833     * and shares it. Nothing is read here.
834     */
835    public static final class Heard extends BroadcastReceiver
836    {
837        @Override
838        public void onReceive(Context context, Intent intent)
839        {
840            if (intent != null && Telephony.Sms.Intents.SMS_RECEIVED_ACTION.equals(intent.getAction())) {
841                touched();
842                StepService.soon(context.getApplicationContext());
843            }
844        }
845    }
846}