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}