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.PendingIntent;
008import android.content.BroadcastReceiver;
009import android.content.Context;
010import android.content.Intent;
011import android.net.Uri;
012import android.os.Build;
013import android.telephony.SmsManager;
014import android.telephony.SmsMessage;
015import android.util.Log;
016import java.io.File;
017import java.io.FileOutputStream;
018import java.io.IOException;
019import java.nio.charset.StandardCharsets;
020import java.util.ArrayList;
021import org.json.JSONException;
022import org.json.JSONObject;
023
024/**
025 * A text written on a computer, sent by this phone (docs/texts.md, "Never
026 * twice"; Rust's side: crates/sioul-app/src/texts.rs). Rust has decided it
027 * and written its key in the phone's private ledger, flushed to the disk,
028 * before it asks here: a key is handed to Android once. Android splits it
029 * (`divideMessage`), sends it from the SIM named, files it in the phone's
030 * sent box with Sioul as its creator, so that the SMS app shows it in the
031 * conversation, and retries by itself three times ("reject duplicates");
032 * Sioul never retries. Each part's sent and delivered results come to
033 * `Result`, which writes them in Java's own file (`results.jsonl`), flushed,
034 * that Rust copies into the phone's outcomes. Java never writes a shared file.
035 */
036final class TextSend
037{
038    static final String SENT = "com.aurelienpierre.sioul.action.TEXT_SENT";
039    static final String DELIVERED = "com.aurelienpierre.sioul.action.TEXT_DELIVERED";
040    static final String MMS_SENT = "com.aurelienpierre.sioul.action.MMS_SENT";
041    /** The provider's root for Sioul's private files (res/xml/qtprovider_paths.xml): the whole of getFilesDir(). */
042    static final String FILES_ROOT = "files_path";
043    /** Java's results, in Sioul's texts folder. */
044    static final String RESULTS = "results.jsonl";
045
046    private TextSend()
047    {
048    }
049
050    /**
051     * The text handed to Android: {handed, parts} or {handed: false, reason}.
052     * The parts' intents name the key, so that each result finds its text.
053     */
054    static JSONObject send(Context context, String key, String to, String body, int sub) throws JSONException
055    {
056        if (key.isEmpty() || to.isEmpty() || body.isEmpty())
057            return new JSONObject().put("handed", false).put("reason", "empty");
058        if (!Texts.allowed(context, Manifest.permission.SEND_SMS))
059            return new JSONObject().put("handed", false).put("reason", "permission");
060        try {
061            SmsManager manager = manager(context, sub);
062            ArrayList<String> parts = manager.divideMessage(body);
063            int count = parts.size();
064            ArrayList<PendingIntent> sent = new ArrayList<>();
065            ArrayList<PendingIntent> delivered = new ArrayList<>();
066            for (int i = 0; i < count; i++) {
067                sent.add(pending(context, SENT, key, i, count));
068                delivered.add(pending(context, DELIVERED, key, i, count));
069            }
070            manager.sendMultipartTextMessage(to, null, parts, sent, delivered);
071            return new JSONObject().put("handed", true).put("parts", count);
072        } catch (RuntimeException e) {
073            Log.w(Texts.TAG, "Texts: Android did not take a text: " + e.getClass().getSimpleName());
074            return new JSONObject().put("handed", false).put("reason", "refused");
075        }
076    }
077
078    /**
079     * A multimedia message written on a computer, which Rust built on this
080     * phone (crates/sioul-core/src/mms.rs), measured against the SIM's
081     * carrier, and claimed in its ledger before it asks here: handed to
082     * Android by its address in Sioul's file provider, through which Android
083     * reads it ({@code sendMultimediaMessage}: SEND_SMS alone, no SMS role),
084     * from the SIM named, never with values of Sioul's over the carrier's.
085     * Android files it in the sent box with Sioul as its creator and its key
086     * as {@code tr_id}; its word, with the carrier's answer, comes to
087     * {@code Result} by an explicit, mutable intent (Android adds its extras
088     * to it). {handed} or {handed: false, reason}.
089     */
090    static JSONObject sendMms(Context context, String key, int sub, String path) throws JSONException
091    {
092        if (key.isEmpty() || path.isEmpty())
093            return new JSONObject().put("handed", false).put("reason", "empty");
094        if (!Texts.allowed(context, Manifest.permission.SEND_SMS))
095            return new JSONObject().put("handed", false).put("reason", "permission");
096        String uri;
097        try {
098            File file = new File(path);
099            uri = file.isFile() ? Attachments.contentUri(context.getPackageName(), context.getFilesDir().getCanonicalPath(), file.getCanonicalPath(), FILES_ROOT) : null;
100        } catch (IOException e) {
101            uri = null;
102        }
103        if (uri == null)
104            return new JSONObject().put("handed", false).put("reason", "mms-unreadable");
105        try {
106            manager(context, sub).sendMultimediaMessage(context, Uri.parse(uri), null, null, pendingMms(context, key));
107            return new JSONObject().put("handed", true);
108        } catch (RuntimeException e) {
109            Log.w(Texts.TAG, "Texts: Android did not take a multimedia message: " + e.getClass().getSimpleName());
110            return new JSONObject().put("handed", false).put("reason", "refused");
111        }
112    }
113
114    /**
115     * A multimedia message's word, to {@code Result}: an explicit intent of
116     * its own (its address names the key), mutable, so that Android may add
117     * the carrier's answer ({@code SmsManager.EXTRA_MMS_DATA}), the HTTP
118     * status and the row it filed ("uri"); from Android 14 a mutable intent
119     * must be explicit.
120     */
121    private static PendingIntent pendingMms(Context context, String key)
122    {
123        Intent intent = new Intent(context, Result.class).setAction(MMS_SENT).setData(Uri.parse("sioul-mms://" + key)).putExtra("key", key);
124        int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
125        return PendingIntent.getBroadcast(context, 0, intent, flags);
126    }
127
128    /**
129     * A multimedia message's word as Rust reads it (crates/sioul-app/src/texts.rs,
130     * {@code mms_outcomes}): its key, "mms", Android's code (-1: done), the row
131     * it filed, the HTTP status, the carrier's answer (M-Send.conf) in hex,
132     * when (ms). Pure: android/jvm-checks/TextsCheck.java.
133     */
134    static JSONObject mmsLine(String key, int code, String uri, int http, byte[] conf, long at) throws JSONException
135    {
136        return new JSONObject().put("key", key == null ? "" : key).put("kind", "mms").put("code", code).put("uri", uri == null ? "" : uri)
137            .put("http", http).put("conf", hex(conf)).put("at", at);
138    }
139
140    /** Bytes in hex, two digits each; "" for none. */
141    static String hex(byte[] bytes)
142    {
143        if (bytes == null)
144            return "";
145        StringBuilder out = new StringBuilder();
146        for (byte b : bytes)
147            out.append(Character.forDigit((b >> 4) & 15, 16)).append(Character.forDigit(b & 15, 16));
148        return out.toString();
149    }
150
151    /** The SIM's own manager (`sub` ≥ 0), else the phone's default one. */
152    @SuppressWarnings("deprecation")
153    static SmsManager manager(Context context, int sub)
154    {
155        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
156            SmsManager manager = context.getSystemService(SmsManager.class);
157            return sub >= 0 ? manager.createForSubscriptionId(sub) : manager;
158        }
159        return sub >= 0 ? SmsManager.getSmsManagerForSubscriptionId(sub) : SmsManager.getDefault();
160    }
161
162    /**
163     * A part's result, to `Result`: one of its own (its address names the
164     * key, the part, sent or delivered), mutable so that Android may add the
165     * row it filed ("uri") and the carrier's report ("pdu").
166     */
167    private static PendingIntent pending(Context context, String action, String key, int part, int parts)
168    {
169        Intent intent = new Intent(context, Result.class).setAction(action)
170            .setData(Uri.parse("sioul-text://" + key + "/" + (SENT.equals(action) ? "sent" : "delivered") + "/" + part))
171            .putExtra("key", key).putExtra("part", part).putExtra("parts", parts);
172        int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0);
173        return PendingIntent.getBroadcast(context, 0, intent, flags);
174    }
175
176    /**
177     * A part's result as Rust reads it (crates/sioul-app/src/texts.rs,
178     * `Result`): its key, "sent" or "delivered", which part of how many,
179     * Android's code (-1: done), the row it filed, the carrier's status for a
180     * delivery (TP-Status: under 0x20, received), when (ms).
181     */
182    static JSONObject line(String key, String kind, int part, int parts, int code, String uri, int status, long at) throws JSONException
183    {
184        return new JSONObject().put("key", key).put("kind", kind).put("part", part).put("parts", parts).put("code", code)
185            .put("uri", uri == null ? "" : uri).put("status", status).put("at", at);
186    }
187
188    /** A line added to Java's results file, flushed to the disk. */
189    static synchronized void append(File folder, String line)
190    {
191        folder.mkdirs();
192        try (FileOutputStream out = new FileOutputStream(new File(folder, RESULTS), true)) {
193            out.write((line + "\n").getBytes(StandardCharsets.UTF_8));
194            out.getFD().sync();
195        } catch (IOException e) {
196            Log.w(Texts.TAG, "Texts: a result not written: " + e);
197        }
198    }
199
200    /** Android's word on a part, sent or delivered, or on a multimedia message (in the background service's process): written, then a step soon. */
201    public static final class Result extends BroadcastReceiver
202    {
203        @Override
204        public void onReceive(Context context, Intent intent)
205        {
206            if (intent == null || intent.getAction() == null)
207                return;
208            if (MMS_SENT.equals(intent.getAction())) {
209                try {
210                    String line = mmsLine(intent.getStringExtra("key"), getResultCode(), intent.getStringExtra("uri"), intent.getIntExtra(SmsManager.EXTRA_MMS_HTTP_STATUS, 0),
211                                          intent.getByteArrayExtra(SmsManager.EXTRA_MMS_DATA), System.currentTimeMillis()).toString();
212                    append(new File(context.getFilesDir(), Texts.FOLDER), line);
213                } catch (JSONException e) {
214                    Log.w(Texts.TAG, "Texts: a result not read: " + e);
215                }
216                StepService.soon(context.getApplicationContext());
217                return;
218            }
219            boolean sent = SENT.equals(intent.getAction());
220            int status = -1;
221            if (!sent) {
222                try {
223                    byte[] pdu = intent.getByteArrayExtra("pdu");
224                    String format = intent.getStringExtra("format");
225                    SmsMessage report = pdu == null ? null : SmsMessage.createFromPdu(pdu, format);
226                    status = report == null ? -1 : report.getStatus();
227                } catch (RuntimeException e) {
228                    // A report that does not read: no word on delivery.
229                }
230            }
231            try {
232                String line = line(intent.getStringExtra("key"), sent ? "sent" : "delivered", intent.getIntExtra("part", 0), intent.getIntExtra("parts", 1),
233                                   getResultCode(), intent.getStringExtra("uri"), status, System.currentTimeMillis()).toString();
234                append(new File(context.getFilesDir(), Texts.FOLDER), line);
235            } catch (JSONException e) {
236                Log.w(Texts.TAG, "Texts: a result not read: " + e);
237            }
238            StepService.soon(context.getApplicationContext());
239        }
240    }
241}