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}