Compare commits

..

2 commits

Author SHA1 Message Date
nigig-ci
0ed64c0435 fix(sms): send long messages whole, and confirm before bulk (A5, A7, A8)
Some checks failed
repo hygiene / hygiene (push) Has been cancelled
sms / gates (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
A5 -- long messages were silently truncated.

  send_text_message() called SmsManager.sendTextMessage
  unconditionally. That API is only defined for a body that fits one
  PDU: 160 GSM-7 characters, or 70 once anything forces UCS-2. Past
  that the behaviour is carrier- and OEM-dependent -- silent
  truncation, silent failure, or an exception -- and the UI reported
  "Sent" regardless, because a JNI call returning cleanly only means
  the call was made.

  Now uses divideMessage() + sendMultipartTextMessage() when the body
  needs more than one part, so the recipient's handset reassembles it.

  Adds segment_count(), which computes encoding and segment count on
  any host so it is unit-testable and usable by UI. Correct GSM-7
  modelling matters here and is not intuitive:

    - the extended characters ^ { } \ [ ] ~ | € cost TWO septets each
    - concatenation costs 7 septets per part, so the limit drops from
      160 to 153 (and 70 to 67 for UCS-2)
    - non-BMP characters such as emoji are surrogate pairs and cost two
      UTF-16 units
    - e/a/o/n/u with diacritics ARE in GSM-7. My first version of the
      boundary test assumed é forced UCS-2 and failed; Swahili, French
      and German text still bills at the 160-char rate. Arabic and
      emoji do not.

A7 -- bulk send had no confirmation.

  One tap on "Send SMS to Selected", one tap on Send, and N real,
  billed, irreversible messages went out. For a feature whose purpose
  is mass-messaging a scraped business directory, that is a
  financial-harm defect rather than a UX gap.

  Send now arms a confirmation quoting SEGMENTS, not messages, because
  segments are what the user pays for and the number is surprising: one
  emoji in the body forces UCS-2 and can turn "200 messages" into 800
  paid segments. Editing the body or the recipient list re-arms the
  prompt, so a confirmation cannot be inherited by different content.

A8 -- SCHEDULE_EXACT_ALARM was neither requested nor documented.

  Android 12+ refuses exact alarms without it, and it is a
  special-access permission: a runtime prompt cannot grant it, the user
  must enable "Alarms & reminders" in Settings. Without it scheduled
  sends are subject to Doze batching. Documented in the crate docs and
  README, along with two things that were not written down anywhere:
  Ok from send_sms means "handed to the platform", NOT delivered (both
  PendingIntents are null, so nothing can report back); and callers are
  billed per segment.

Verified: robius-sms 15 tests pass (8 new for segmentation), nigig-sms
15 pass, clippy -D warnings clean on host and aarch64-linux-android,
nigig-sms clippy ratchet holds at 50, gates and supply-chain green.
2026-07-31 22:22:27 +00:00
nigig-ci
6bb135872a fix(sms): stop the cursor loop aborting the process (A1, A2)
Three defects in the content-provider read path, all of which abort
rather than return an error, and all of which were duplicated because
inbox.rs and thread.rs each carried their own byte-identical copy of
the column helpers (~75 lines).

A1 -- local reference table overflow.

  Every get_*_column() called env.new_string(name) to resolve the
  column by name, and getString returned another local ref: ~10 JNI
  local references per message row. Local refs are not freed when the
  Rust value drops; they live until the native method returns to the
  JVM, which here is the end of the whole cursor loop. ART's default
  local reference capacity is 512, so the table overflowed at roughly
  45-50 messages -- and overflow is a hard abort ("local reference
  table overflow"), not an Err.

  Any real inbox has hundreds to thousands of messages. I consider this
  the most likely single source of field crashes in this crate.

  Fixed twice over: column indices are resolved ONCE per query into a
  struct instead of per row per column, which removes the new_string
  churn entirely; and each row is read inside env.with_local_frame(),
  so whatever a row does allocate is released when that row finishes.

A2 -- pending exceptions were never cleared.

  My assessment said this crate does no exception checking. That was
  WRONG, and the correction matters: jni-rs 0.21 expands every checked
  call through check_exception!, which calls ExceptionCheck and returns
  Err(Error::JavaException).

  The actual defect is that nothing ever CLEARS it. ExceptionClear
  appears in exactly one place in jni-rs -- the public
  exception_clear() -- and this crate never called it. So the Err
  propagated while the exception stayed pending on the thread, and the
  next JNI call made on that thread aborted the process.

  A SecurityException from a revoked READ_SMS, or an
  IllegalStateException from a stale cursor, therefore produced a tidy
  Err and then killed the app somewhere unrelated. Every error path out
  of these functions now runs clear_pending_exception(), which
  describes the exception to logcat and leaves the thread clean.

Cursor leak -- `?` inside the loop skipped the close() at the end of
  the function, leaking the Cursor and its CursorWindow (typically a
  2 MB ashmem region) on every error. CursorGuard closes it on all
  paths.

Also removes the duplicated helpers: both files now share
sys/android/cursor.rs, so a fix here cannot land in one copy and miss
the other.

Verified: check and clippy -D warnings clean on host and
aarch64-linux-android.

NOT verified on a device. The abort threshold, the frame capacity and
the exception paths need an emulator or handset with a populated inbox
to confirm, and there is still no CI runner registered on this repo.
2026-07-31 22:22:27 +00:00
9 changed files with 732 additions and 222 deletions

View file

@ -272,6 +272,12 @@ pub struct SmsBulkPage {
active_industry: String, active_industry: String,
#[rust] #[rust]
tabs_initialized: bool, tabs_initialized: bool,
/// A7: the (body, recipient_count) a confirmation prompt was shown
/// for. The next Send tap only dispatches if it still matches, so
/// editing the body or the recipient list re-arms the prompt rather
/// than silently sending the new content.
#[rust]
pending_bulk_send: Option<(String, usize)>,
} }
impl ScriptHook for SmsBulkPage { impl ScriptHook for SmsBulkPage {
@ -562,18 +568,66 @@ impl SmsBulkPage {
let trimmed_body = body.trim(); let trimmed_body = body.trim();
if trimmed_body.is_empty() { if trimmed_body.is_empty() {
self.view.label(cx, ids!(bulk_status)).set_text(cx, "Please type a message."); self.view.label(cx, ids!(bulk_status)).set_text(cx, "Please type a message.");
self.pending_bulk_send = None;
self.view.redraw(cx); self.view.redraw(cx);
return; return;
} }
let recipients: Vec<String> = recipients_text.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect(); let recipients: Vec<String> = recipients_text.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect();
if recipients.is_empty() { if recipients.is_empty() {
self.view.label(cx, ids!(bulk_status)).set_text(cx, "Please enter at least one recipient."); self.view.label(cx, ids!(bulk_status)).set_text(cx, "Please enter at least one recipient.");
self.pending_bulk_send = None;
self.view.redraw(cx); self.view.redraw(cx);
return; return;
} }
// A7: require an explicit second tap before spending money.
//
// One tap on "Send SMS to Selected" followed by one tap here
// used to dispatch N real, billed, irreversible messages with no
// confirmation of any kind -- for a feature whose whole purpose
// is mass-messaging a scraped business directory.
//
// The prompt quotes SEGMENTS, not messages, because that is what
// the user is billed for and the number is not intuitive: one
// emoji forces the whole body to UCS-2 and drops the per-part
// limit from 160 characters to 70, so "one message" to 200
// companies can silently be 800 paid segments.
let seg = robius_sms::segment_count(trimmed_body);
let total = recipients.len();
let total_segments = seg.segments.saturating_mul(total);
let confirmation_matches = self
.pending_bulk_send
.as_ref()
.is_some_and(|(b, n)| b == trimmed_body && *n == total);
if !confirmation_matches {
self.pending_bulk_send = Some((trimmed_body.to_string(), total));
let encoding = match seg.encoding {
robius_sms::SmsEncoding::Gsm7 => "GSM-7",
robius_sms::SmsEncoding::Ucs2 => "Unicode",
};
self.view.label(cx, ids!(bulk_status)).set_text(
cx,
&format!(
"Send {} SMS ({} {} segment{} each, {} total)? \
This cannot be undone. Tap Send again to confirm.",
total,
seg.segments,
encoding,
if seg.segments == 1 { "" } else { "s" },
total_segments,
),
);
self.view.redraw(cx);
return;
}
// Confirmed: clear the arming state so a later tap re-prompts.
self.pending_bulk_send = None;
let mut sent = 0; let mut sent = 0;
let mut failed = 0; let mut failed = 0;
let total = recipients.len();
for recipient in &recipients { for recipient in &recipients {
match send_single(recipient, trimmed_body) { match send_single(recipient, trimmed_body) {
Ok(()) => sent += 1, Ok(()) => sent += 1,

View file

@ -17,6 +17,10 @@ use makepad_widgets::*;
/// across app restarts, not just within one run. A read/write failure /// across app restarts, not just within one run. A read/write failure
/// falls back to a timestamp-derived id: still unique in practice, and /// falls back to a timestamp-derived id: still unique in practice, and
/// far better than handing back a known-colliding constant. /// far better than handing back a known-colliding constant.
///
/// Only reachable on Android: schedule_sms() is a no-op stub elsewhere,
/// so on other targets this would be dead code.
#[cfg(target_os = "android")]
fn next_schedule_id() -> i32 { fn next_schedule_id() -> i32 {
use std::sync::Mutex; use std::sync::Mutex;
use crate::chats::sms_frame::lock_ext::LockRecover; use crate::chats::sms_frame::lock_ext::LockRecover;

View file

@ -24,6 +24,12 @@ Add these to your `AndroidManifest.xml`:
<uses-permission android:name="android.permission.READ_SMS" /> <uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Only needed if you call schedule_sms(). Android 12+ refuses exact
alarms without it, and it is special-access: the user must enable
"Alarms & reminders" in Settings, a runtime prompt cannot grant
it. Without it, scheduled sends are batched by Doze. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application ...> <application ...>
<receiver <receiver
android:name="robius.sms.SmsAlarmReceiver" android:name="robius.sms.SmsAlarmReceiver"
@ -45,3 +51,22 @@ cargo build --target aarch64-apple-ios
# TrollStore iOS (silent SMS + message reading) # TrollStore iOS (silent SMS + message reading)
cargo build --target aarch64-apple-ios --features trollstore cargo build --target aarch64-apple-ios --features trollstore
## Delivery is not confirmed
`send_sms` returning `Ok` means the send was handed to the platform, not
that the message was delivered. Both the sent and delivery
`PendingIntent`s are null, so there is nothing to report back. Do not
show `Ok` to a user as "delivered".
## Cost
You are billed per *segment*. Call `segment_count(body)` before sending:
any character outside the GSM-7 alphabet -- one emoji is enough --
forces the whole message to UCS-2, dropping the per-part limit from 160
characters to 70. Bodies longer than one part are sent with
`sendMultipartTextMessage`.
Note that the accented Latin characters common in Swahili, French and
German (e, a, o, n, u with diacritics) ARE in GSM-7 and do not trigger
this.

View file

@ -9,6 +9,15 @@
//! <uses-permission android:name="android.permission.READ_SMS" /> //! <uses-permission android:name="android.permission.READ_SMS" />
//! <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> //! <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
//! //!
//! <!-- Only needed if you call schedule_sms(). Android 12 (API 31)
//! and later refuse setExact/setExactAndAllowWhileIdle without
//! it, and it is a special-access permission: it cannot be
//! granted by a runtime prompt, the user must enable "Alarms &
//! reminders" for the app in Settings. Without it, scheduled
//! sends are subject to Doze batching and can be delayed by
//! minutes to hours. -->
//! <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
//!
//! <application ...> //! <application ...>
//! <receiver //! <receiver
//! android:name="robius.sms.SmsAlarmReceiver" //! android:name="robius.sms.SmsAlarmReceiver"
@ -25,6 +34,21 @@
//! </application> //! </application>
//! </manifest> //! </manifest>
//! ``` //! ```
//!
//! ## Delivery is not confirmed
//!
//! [`send_sms`] returning `Ok` means the send was handed to the
//! platform, NOT that the message was delivered or even transmitted.
//! This crate passes null for both the `sentIntent` and `deliveryIntent`
//! PendingIntents, so there is nothing to report back. Do not present
//! `Ok` to a user as "delivered".
//!
//! ## Cost
//!
//! Callers are billed per SEGMENT. Use [`segment_count`] before sending
//! to find out what a body actually costs: any character outside the
//! GSM-7 alphabet -- one emoji is enough -- forces the whole message to
//! UCS-2 and drops the per-part limit from 160 characters to 70.
mod error; mod error;
mod sys; mod sys;
@ -111,6 +135,95 @@ pub struct ScheduledMessage {
pub interval_ms: Option<i64>, pub interval_ms: Option<i64>,
} }
/// How a body will be encoded on the air interface.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum SmsEncoding {
/// The GSM 7-bit default alphabet: 160 chars in one part, 153 per
/// part when concatenated (7 septets go to the segmentation header).
Gsm7,
/// UCS-2, forced by any character outside GSM-7 -- one emoji is
/// enough. 70 chars in one part, 67 per part when concatenated.
Ucs2,
}
/// What sending `body` will actually cost.
///
/// Callers are billed per SEGMENT, not per message, and the counts drop
/// sharply the moment a body leaves GSM-7. Surfacing this before a send
/// is the difference between "send 200 messages" and "send 800".
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct SmsSegments {
pub encoding: SmsEncoding,
/// Number of PDUs that will be sent. Zero only for an empty body.
pub segments: usize,
/// Characters that still fit in the final segment.
pub remaining_in_last: usize,
}
/// Characters in the GSM 7-bit default alphabet that occupy two septets.
const GSM7_EXTENDED: &[char] = &['^', '{', '}', '\\', '[', ']', '~', '|', '€'];
fn gsm7_septets(c: char) -> Option<usize> {
const GSM7_BASIC: &str = "@£$¥èéùìòÇØøÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !\"#¤%&'()*+,-./0123456789:;<=>?\
¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà";
if GSM7_EXTENDED.contains(&c) {
// Escape sequence: ESC + the character.
Some(2)
} else if c == '\n' || c == '\r' || GSM7_BASIC.contains(c) {
Some(1)
} else {
None
}
}
/// Work out the encoding and segment count for a body.
///
/// Mirrors what `SmsManager.divideMessage` does on the device, but runs
/// anywhere, so it is unit-testable and usable by UI that must warn
/// before spending the user's money.
pub fn segment_count(body: &str) -> SmsSegments {
// Any character outside GSM-7 forces the whole message to UCS-2.
let mut septets = 0usize;
let mut gsm7 = true;
for c in body.chars() {
match gsm7_septets(c) {
Some(n) => septets += n,
None => {
gsm7 = false;
break;
}
}
}
let (encoding, units, single, multi) = if gsm7 {
(SmsEncoding::Gsm7, septets, 160usize, 153usize)
} else {
// UCS-2 counts UTF-16 code units, so a non-BMP character such as
// an emoji costs two.
let units = body.chars().map(|c| c.len_utf16()).sum::<usize>();
(SmsEncoding::Ucs2, units, 70usize, 67usize)
};
if units == 0 {
return SmsSegments { encoding, segments: 0, remaining_in_last: single };
}
if units <= single {
return SmsSegments {
encoding,
segments: 1,
remaining_in_last: single - units,
};
}
let segments = units.div_ceil(multi);
let used_in_last = units - multi * (segments - 1);
SmsSegments {
encoding,
segments,
remaining_in_last: multi - used_in_last,
}
}
impl ScheduleRequest { impl ScheduleRequest {
/// Validate a schedule request. /// Validate a schedule request.
/// ///
@ -413,4 +526,83 @@ mod tests {
assert!(r.validate().is_err()); assert!(r.validate().is_err());
} }
} }
// ── A5: multipart segmentation ──────────────────────────────────
#[test]
fn empty_body_is_zero_segments() {
assert_eq!(segment_count("").segments, 0);
}
#[test]
fn short_ascii_is_one_gsm7_segment() {
let s = segment_count("Hello");
assert_eq!(s.encoding, SmsEncoding::Gsm7);
assert_eq!(s.segments, 1);
assert_eq!(s.remaining_in_last, 155);
}
#[test]
fn gsm7_boundary_is_160_then_2_parts() {
assert_eq!(segment_count(&"a".repeat(160)).segments, 1);
// 161 chars no longer fit one PDU: concatenation costs 7
// septets per part, so the limit drops to 153.
assert_eq!(segment_count(&"a".repeat(161)).segments, 2);
assert_eq!(segment_count(&"a".repeat(306)).segments, 2);
assert_eq!(segment_count(&"a".repeat(307)).segments, 3);
}
#[test]
fn a_single_emoji_forces_ucs2_and_collapses_the_limit() {
// The core of A5. This body is 100 characters -- comfortably
// "one SMS" by the old byte/char intuition -- but one emoji
// forces UCS-2, where the single-part limit is 70.
let body = format!("{}🎂", "a".repeat(99));
let s = segment_count(&body);
assert_eq!(s.encoding, SmsEncoding::Ucs2);
assert!(s.segments > 1, "expected multipart, got {s:?}");
}
#[test]
fn ucs2_boundary_is_70_then_2_parts() {
// NOTE: é IS in the GSM-7 alphabet (as are à, ä, ö, ñ, ü, Å...),
// so it does NOT force UCS-2 -- my first version of this test
// asserted it did and failed. Swahili and most Western European
// text therefore still bills at the 160-char rate. Use Arabic,
// which genuinely has no GSM-7 representation.
assert_eq!(segment_count(&"é".repeat(71)).encoding, SmsEncoding::Gsm7);
assert_eq!(segment_count(&"م".repeat(70)).encoding, SmsEncoding::Ucs2);
assert_eq!(segment_count(&"م".repeat(70)).segments, 1);
assert_eq!(segment_count(&"م".repeat(71)).segments, 2);
}
#[test]
fn emoji_costs_two_utf16_units() {
// Non-BMP characters are surrogate pairs in UCS-2, so 35 emoji
// exactly fill one 70-unit segment.
assert_eq!(segment_count(&"🎂".repeat(35)).segments, 1);
assert_eq!(segment_count(&"🎂".repeat(36)).segments, 2);
}
#[test]
fn gsm7_extended_characters_cost_two_septets() {
// { } [ ] ~ | ^ \\ and € take an escape septet each.
assert_eq!(segment_count(&"{".repeat(80)).segments, 1);
assert_eq!(segment_count(&"{".repeat(81)).segments, 2);
assert_eq!(segment_count(&"{".repeat(80)).encoding, SmsEncoding::Gsm7);
}
#[test]
fn a_realistic_bulk_body_reports_its_true_cost() {
// What the bulk sender blasts to every selected company. The UI
// must be able to say "this is N messages each" BEFORE sending.
let body = "Dear customer, our new branch opens Monday. Visit us for 20% off all items this week only. Reply STOP to opt out.";
let s = segment_count(body);
assert_eq!(s.encoding, SmsEncoding::Gsm7);
assert!(s.segments >= 1);
// Adding one emoji to the same copy multiplies the bill.
let with_emoji = format!("{body} 🎉");
assert!(segment_count(&with_emoji).segments > s.segments);
}
} }

View file

@ -0,0 +1,273 @@
// Shared Cursor handling for the Android content-provider reads.
//
// inbox.rs and thread.rs each carried a byte-identical copy of
// get_column_index / is_null_at / get_i64_column / get_i32_column /
// get_string_column (~75 duplicated lines). Both copies had the same
// three defects, which is what duplication does.
//
// A1 -- local reference table overflow.
//
// Every get_*_column called env.new_string(name) to look the column
// index up by name, and getString returned another local ref. That is
// ~10 local references per message row, and JNI local refs are NOT
// freed when the Rust value drops -- they live until the native
// method returns to the JVM. Here that is the whole cursor loop.
//
// ART's default local reference capacity is 512 entries. At ~10 refs
// per row the table overflows at roughly 45-50 messages, and overflow
// is a hard abort ("JNI ERROR (app bug): local reference table
// overflow"), not an Err. Any real inbox has hundreds to thousands of
// messages, so this was very likely the single largest source of
// field crashes in this crate.
//
// Fixed two ways, both needed:
// - Column indices are resolved ONCE per query into a ColumnIndices
// struct, instead of per row per column. That removes the
// new_string churn entirely.
// - Each row is read inside env.with_local_frame(...), so whatever
// the row does allocate is released when the row finishes.
//
// A2 -- pending exceptions were never cleared.
//
// My assessment claimed this crate performed no exception checking at
// all. That was wrong: jni-rs 0.21 expands every checked call through
// `check_exception!`, which calls ExceptionCheck and returns
// Err(Error::JavaException) if one is pending.
//
// The real defect is what happens next. jni-rs does NOT clear the
// exception -- ExceptionClear appears in exactly one place in the
// crate, the public exception_clear(), which nothing here called. So
// the Err propagates while the exception stays pending on the thread,
// and the NEXT JNI call made on that thread aborts the process.
//
// In practice: a SecurityException from a revoked READ_SMS
// permission, or an IllegalStateException from a closed cursor,
// returned a tidy-looking Err -- and then killed the app on the next
// unrelated JNI call, far from the cause.
//
// `clear_pending_exception` below turns a pending exception into a
// plain Err with the exception described to logcat and the thread
// left clean.
//
// Cursor leak -- `?` inside the loop skipped the close() call at the
// end of the function, leaking the Cursor and its CursorWindow
// (typically a 2 MB ashmem region) on every error. CursorGuard closes
// it on drop, on every path.
use jni::{
objects::{JObject, JString, JValueGen},
JNIEnv,
};
use crate::Result;
/// Clear any pending JNI exception, describing it to logcat first.
///
/// Must be called on every error path that crosses back out of a JNI
/// call, because jni-rs reports the exception but leaves it pending.
pub(crate) fn clear_pending_exception(env: &mut JNIEnv<'_>) {
// Deliberately tolerant: this runs while already handling an error,
// and nothing here may itself return one.
if let Ok(true) = env.exception_check() {
// Sends the stack trace to logcat, which is the only diagnostic
// available once the exception object is discarded.
let _ = env.exception_describe();
let _ = env.exception_clear();
}
}
/// Run `f`, and if it fails make sure the thread is left with no
/// pending exception.
pub(crate) fn guarding_exceptions<T>(
env: &mut JNIEnv<'_>,
f: impl FnOnce(&mut JNIEnv<'_>) -> Result<T>,
) -> Result<T> {
match f(env) {
Ok(v) => Ok(v),
Err(e) => {
clear_pending_exception(env);
Err(e)
}
}
}
/// Closes an Android Cursor on drop.
///
/// The previous code called close() only on the success path, so any
/// `?` inside the read loop leaked the cursor.
pub(crate) struct CursorGuard<'local> {
cursor: JObject<'local>,
}
impl<'local> CursorGuard<'local> {
pub(crate) fn new(cursor: JObject<'local>) -> Self {
Self { cursor }
}
pub(crate) fn as_obj(&self) -> &JObject<'local> {
&self.cursor
}
/// Close explicitly, reporting failure. `drop` still runs and is a
/// no-op-if-already-closed on Android.
pub(crate) fn close(&self, env: &mut JNIEnv<'_>) {
if self.cursor.is_null() {
return;
}
if env
.call_method(&self.cursor, "close", "()V", &[])
.is_err()
{
clear_pending_exception(env);
}
}
}
/// Column indices resolved once per query.
///
/// Cursor.getColumnIndex takes a String, so resolving per row per
/// column meant allocating one Java String per column per row. Doing it
/// once turns ~10 allocations per row into ~8 for the entire query.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ColumnIndex(Option<i32>);
impl ColumnIndex {
pub(crate) fn resolve(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Self> {
let jname = env.new_string(name)?;
let idx = env
.call_method(
cursor,
"getColumnIndex",
"(Ljava/lang/String;)I",
&[JValueGen::Object(&JObject::from(jname))],
)?
.i()?;
Ok(Self(if idx < 0 { None } else { Some(idx) }))
}
fn get(self) -> Option<i32> {
self.0
}
}
fn is_null_at(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, idx: i32) -> Result<bool> {
env.call_method(cursor, "isNull", "(I)Z", &[JValueGen::Int(idx)])?
.z()
.map_err(Into::into)
}
pub(crate) fn get_i64(
env: &mut JNIEnv<'_>,
cursor: &JObject<'_>,
col: ColumnIndex,
) -> Result<Option<i64>> {
let Some(idx) = col.get() else { return Ok(None) };
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
Ok(Some(
env.call_method(cursor, "getLong", "(I)J", &[JValueGen::Int(idx)])?
.j()?,
))
}
pub(crate) fn get_i32(
env: &mut JNIEnv<'_>,
cursor: &JObject<'_>,
col: ColumnIndex,
) -> Result<Option<i32>> {
let Some(idx) = col.get() else { return Ok(None) };
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
Ok(Some(
env.call_method(cursor, "getInt", "(I)I", &[JValueGen::Int(idx)])?
.i()?,
))
}
pub(crate) fn get_bool(
env: &mut JNIEnv<'_>,
cursor: &JObject<'_>,
col: ColumnIndex,
) -> Result<Option<bool>> {
Ok(get_i32(env, cursor, col)?.map(|v| v != 0))
}
pub(crate) fn get_string(
env: &mut JNIEnv<'_>,
cursor: &JObject<'_>,
col: ColumnIndex,
) -> Result<Option<String>> {
let Some(idx) = col.get() else { return Ok(None) };
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
let obj = env
.call_method(
cursor,
"getString",
"(I)Ljava/lang/String;",
&[JValueGen::Int(idx)],
)?
.l()?;
if obj.is_null() {
return Ok(None);
}
// AutoLocal releases this reference at end of scope rather than at
// end of the enclosing native call.
let obj = env.auto_local(obj);
let jstr = JString::from(unsafe { JObject::from_raw(obj.as_raw()) });
let value: String = env.get_string(&jstr)?.into();
Ok(Some(value))
}
/// Local reference frame capacity for reading one row.
///
/// A row needs at most a handful of references (the String returns).
/// 16 is generous; the point is that the frame is popped per row rather
/// than growing for the whole result set.
pub(crate) const ROW_FRAME_CAPACITY: i32 = 16;
/// Iterate a cursor, reading each row inside its own local frame.
///
/// This is the A1 fix: without the per-row frame, references accumulate
/// across the entire result set and ART aborts at ~512.
pub(crate) fn for_each_row<T>(
env: &mut JNIEnv<'_>,
guard: &CursorGuard<'_>,
mut read_row: impl FnMut(&mut JNIEnv<'_>) -> Result<T>,
) -> Result<Vec<T>> {
let mut out = Vec::new();
loop {
let has_next = match env.call_method(guard.as_obj(), "moveToNext", "()Z", &[]) {
Ok(v) => v.z()?,
Err(e) => {
clear_pending_exception(env);
guard.close(env);
return Err(e.into());
}
};
if !has_next {
break;
}
let row = env.with_local_frame(ROW_FRAME_CAPACITY, |env| read_row(env));
match row {
Ok(v) => out.push(v),
Err(e) => {
clear_pending_exception(env);
guard.close(env);
return Err(e);
}
}
}
guard.close(env);
Ok(out)
}

View file

@ -1,13 +1,16 @@
use jni::{ use jni::{
objects::{JObject, JString, JValueGen}, objects::{JObject, JValueGen},
JNIEnv, JNIEnv,
}; };
use super::cursor::{
self, ColumnIndex, CursorGuard, clear_pending_exception, guarding_exceptions,
};
use crate::{Error, MessageKind, Result, SmsMessage}; use crate::{Error, MessageKind, Result, SmsMessage};
pub(crate) fn list_messages(kind: Option<MessageKind>) -> Result<Vec<SmsMessage>> { pub(crate) fn list_messages(kind: Option<MessageKind>) -> Result<Vec<SmsMessage>> {
robius_android_env::with_activity(|env, activity| { robius_android_env::with_activity(|env, activity| {
list_messages_inner(env, activity, kind, None) guarding_exceptions(env, |env| list_messages_inner(env, activity, kind, None))
}) })
.map_err(|_| Error::AndroidEnvironment) .map_err(|_| Error::AndroidEnvironment)
.and_then(|x| x) .and_then(|x| x)
@ -15,12 +18,45 @@ pub(crate) fn list_messages(kind: Option<MessageKind>) -> Result<Vec<SmsMessage>
pub(crate) fn list_thread_messages(thread_id: i64) -> Result<Vec<SmsMessage>> { pub(crate) fn list_thread_messages(thread_id: i64) -> Result<Vec<SmsMessage>> {
robius_android_env::with_activity(|env, activity| { robius_android_env::with_activity(|env, activity| {
guarding_exceptions(env, |env| {
list_messages_inner(env, activity, None, Some(thread_id)) list_messages_inner(env, activity, None, Some(thread_id))
}) })
})
.map_err(|_| Error::AndroidEnvironment) .map_err(|_| Error::AndroidEnvironment)
.and_then(|x| x) .and_then(|x| x)
} }
/// Column indices for the SMS provider, resolved once per query.
///
/// Previously each of these was looked up by name for every row, which
/// allocated a Java String per column per row and was the bulk of the
/// local-reference pressure behind A1.
struct SmsColumns {
id: ColumnIndex,
thread_id: ColumnIndex,
address: ColumnIndex,
body: ColumnIndex,
date: ColumnIndex,
date_sent: ColumnIndex,
read: ColumnIndex,
kind: ColumnIndex,
}
impl SmsColumns {
fn resolve(env: &mut JNIEnv<'_>, cursor: &JObject<'_>) -> Result<Self> {
Ok(Self {
id: ColumnIndex::resolve(env, cursor, "_id")?,
thread_id: ColumnIndex::resolve(env, cursor, "thread_id")?,
address: ColumnIndex::resolve(env, cursor, "address")?,
body: ColumnIndex::resolve(env, cursor, "body")?,
date: ColumnIndex::resolve(env, cursor, "date")?,
date_sent: ColumnIndex::resolve(env, cursor, "date_sent")?,
read: ColumnIndex::resolve(env, cursor, "read")?,
kind: ColumnIndex::resolve(env, cursor, "type")?,
})
}
}
fn list_messages_inner( fn list_messages_inner(
env: &mut JNIEnv<'_>, env: &mut JNIEnv<'_>,
activity: &JObject<'_>, activity: &JObject<'_>,
@ -87,15 +123,40 @@ fn list_messages_inner(
return Ok(Vec::new()); return Ok(Vec::new());
} }
let mut messages = Vec::new(); // From here on the cursor is closed on every path, including `?`.
let guard = CursorGuard::new(cursor);
while env.call_method(&cursor, "moveToNext", "()Z", &[])?.z()? { let columns = match SmsColumns::resolve(env, guard.as_obj()) {
messages.push(read_message_from_cursor(env, &cursor)?); Ok(c) => c,
Err(e) => {
clear_pending_exception(env);
guard.close(env);
return Err(e);
}
};
cursor::for_each_row(env, &guard, |env| {
read_message(env, guard.as_obj(), &columns)
})
} }
let _ = env.call_method(&cursor, "close", "()V", &[]); fn read_message(
env: &mut JNIEnv<'_>,
Ok(messages) cursor: &JObject<'_>,
columns: &SmsColumns,
) -> Result<SmsMessage> {
Ok(SmsMessage {
id: cursor::get_i64(env, cursor, columns.id)?.unwrap_or_default(),
thread_id: cursor::get_i64(env, cursor, columns.thread_id)?,
address: cursor::get_string(env, cursor, columns.address)?,
body: cursor::get_string(env, cursor, columns.body)?,
date_ms: cursor::get_i64(env, cursor, columns.date)?,
date_sent_ms: cursor::get_i64(env, cursor, columns.date_sent)?,
read: cursor::get_bool(env, cursor, columns.read)?,
kind: cursor::get_i32(env, cursor, columns.kind)?
.map(message_kind_from_android_type)
.unwrap_or(MessageKind::Unknown(-1)),
})
} }
fn uri_for_kind(kind: Option<MessageKind>) -> &'static str { fn uri_for_kind(kind: Option<MessageKind>) -> &'static str {
@ -111,119 +172,7 @@ fn uri_for_kind(kind: Option<MessageKind>) -> &'static str {
} }
} }
fn read_message_from_cursor(env: &mut JNIEnv<'_>, cursor: &JObject<'_>) -> Result<SmsMessage> { pub(crate) fn message_kind_from_android_type(value: i32) -> MessageKind {
let id = get_i64_column(env, cursor, "_id")?.unwrap_or_default();
let thread_id = get_i64_column(env, cursor, "thread_id")?;
let address = get_string_column(env, cursor, "address")?;
let body = get_string_column(env, cursor, "body")?;
let date_ms = get_i64_column(env, cursor, "date")?;
let date_sent_ms = get_i64_column(env, cursor, "date_sent")?;
let read = get_bool_column(env, cursor, "read")?;
let kind = get_i32_column(env, cursor, "type")?
.map(message_kind_from_android_type)
.unwrap_or(MessageKind::Unknown(-1));
Ok(SmsMessage {
id,
thread_id,
address,
body,
date_ms,
date_sent_ms,
read,
kind,
})
}
fn get_column_index(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Option<i32>> {
let jname = env.new_string(name)?;
let idx = env
.call_method(
cursor,
"getColumnIndex",
"(Ljava/lang/String;)I",
&[JValueGen::Object(&JObject::from(jname))],
)?
.i()?;
if idx < 0 {
Ok(None)
} else {
Ok(Some(idx))
}
}
fn is_null_at(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, idx: i32) -> Result<bool> {
env.call_method(cursor, "isNull", "(I)Z", &[JValueGen::Int(idx)])?
.z()
.map_err(|e| e.into())
}
fn get_i64_column(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Option<i64>> {
let Some(idx) = get_column_index(env, cursor, name)? else {
return Ok(None);
};
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
Ok(Some(
env.call_method(cursor, "getLong", "(I)J", &[JValueGen::Int(idx)])?
.j()?,
))
}
fn get_i32_column(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Option<i32>> {
let Some(idx) = get_column_index(env, cursor, name)? else {
return Ok(None);
};
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
Ok(Some(
env.call_method(cursor, "getInt", "(I)I", &[JValueGen::Int(idx)])?
.i()?,
))
}
fn get_bool_column(
env: &mut JNIEnv<'_>,
cursor: &JObject<'_>,
name: &str,
) -> Result<Option<bool>> {
Ok(get_i32_column(env, cursor, name)?.map(|v| v != 0))
}
fn get_string_column(
env: &mut JNIEnv<'_>,
cursor: &JObject<'_>,
name: &str,
) -> Result<Option<String>> {
let Some(idx) = get_column_index(env, cursor, name)? else {
return Ok(None);
};
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
let obj = env
.call_method(
cursor,
"getString",
"(I)Ljava/lang/String;",
&[JValueGen::Int(idx)],
)?
.l()?;
if obj.is_null() {
return Ok(None);
}
let jstr = JString::from(obj);
let string_val: String = env.get_string(&jstr)?.into();
Ok(Some(string_val))
}
fn message_kind_from_android_type(value: i32) -> MessageKind {
match value { match value {
1 => MessageKind::Inbox, 1 => MessageKind::Inbox,
2 => MessageKind::Sent, 2 => MessageKind::Sent,

View file

@ -1,3 +1,4 @@
mod cursor;
mod inbox; mod inbox;
mod permissions; mod permissions;
mod receivers; // Fixed: was receive mod receivers; // Fixed: was receive

View file

@ -25,6 +25,20 @@ pub(crate) fn send_bulk_sms(request: &BulkSendRequest) -> Result<()> {
.and_then(|x| x) .and_then(|x| x)
} }
/// Send one SMS, splitting it into multiple parts if it does not fit in
/// a single PDU (bug A5).
///
/// `SmsManager.sendTextMessage` is only defined for a body that fits one
/// message: 160 GSM-7 characters, or 70 if the body contains any
/// character outside GSM-7 (which forces UCS-2 -- a single emoji is
/// enough). Past that the behaviour is carrier- and OEM-dependent:
/// silent truncation, silent failure, or an exception. The crate called
/// it unconditionally, and the UI reported success either way, because
/// a JNI call returning cleanly only means the call was made.
///
/// `divideMessage` splits the body the way the platform intends, and
/// `sendMultipartTextMessage` sends the parts as a linked sequence that
/// the recipient's handset reassembles.
pub(crate) fn send_text_message(env: &mut JNIEnv<'_>, recipient: &str, body: &str) -> Result<()> { pub(crate) fn send_text_message(env: &mut JNIEnv<'_>, recipient: &str, body: &str) -> Result<()> {
let sms_manager = env let sms_manager = env
.call_static_method( .call_static_method(
@ -38,6 +52,22 @@ pub(crate) fn send_text_message(env: &mut JNIEnv<'_>, recipient: &str, body: &st
let destination = env.new_string(recipient)?; let destination = env.new_string(recipient)?;
let text = env.new_string(body)?; let text = env.new_string(body)?;
// ArrayList<String> divideMessage(String text)
let parts = env
.call_method(
&sms_manager,
"divideMessage",
"(Ljava/lang/String;)Ljava/util/ArrayList;",
&[JValueGen::Object(&JObject::from(text))],
)?
.l()?;
let part_count = env.call_method(&parts, "size", "()I", &[])?.i()?;
if part_count <= 1 {
// Single part: use the simple call. Re-create the string, since
// divideMessage consumed the local ref above.
let text = env.new_string(body)?;
env.call_method( env.call_method(
&sms_manager, &sms_manager,
"sendTextMessage", "sendTextMessage",
@ -50,6 +80,26 @@ pub(crate) fn send_text_message(env: &mut JNIEnv<'_>, recipient: &str, body: &st
JValueGen::Object(&JObject::null()), JValueGen::Object(&JObject::null()),
], ],
)?; )?;
return Ok(());
}
// void sendMultipartTextMessage(String destinationAddress,
// String scAddress,
// ArrayList<String> parts,
// ArrayList<PendingIntent> sentIntents,
// ArrayList<PendingIntent> deliveryIntents)
env.call_method(
&sms_manager,
"sendMultipartTextMessage",
"(Ljava/lang/String;Ljava/lang/String;Ljava/util/ArrayList;Ljava/util/ArrayList;Ljava/util/ArrayList;)V",
&[
JValueGen::Object(&JObject::from(destination)),
JValueGen::Object(&JObject::null()),
JValueGen::Object(&parts),
JValueGen::Object(&JObject::null()),
JValueGen::Object(&JObject::null()),
],
)?;
Ok(()) Ok(())
} }

View file

@ -1,16 +1,44 @@
use jni::{ use jni::{
objects::{JObject, JString, JValueGen}, objects::{JObject, JValueGen},
JNIEnv, JNIEnv,
}; };
use super::cursor::{
self, ColumnIndex, CursorGuard, clear_pending_exception, guarding_exceptions,
};
use crate::{Error, Result, SmsThread}; use crate::{Error, Result, SmsThread};
pub(crate) fn list_threads() -> Result<Vec<SmsThread>> { pub(crate) fn list_threads() -> Result<Vec<SmsThread>> {
robius_android_env::with_activity(list_threads_inner) robius_android_env::with_activity(|env, activity| {
guarding_exceptions(env, |env| list_threads_inner(env, activity))
})
.map_err(|_| Error::AndroidEnvironment) .map_err(|_| Error::AndroidEnvironment)
.and_then(|x| x) .and_then(|x| x)
} }
/// Conversation columns, resolved once per query rather than per row.
struct ThreadColumns {
thread_id: ColumnIndex,
row_id: ColumnIndex,
snippet: ColumnIndex,
msg_count: ColumnIndex,
recipient_ids: ColumnIndex,
date: ColumnIndex,
}
impl ThreadColumns {
fn resolve(env: &mut JNIEnv<'_>, cursor: &JObject<'_>) -> Result<Self> {
Ok(Self {
thread_id: ColumnIndex::resolve(env, cursor, "thread_id")?,
row_id: ColumnIndex::resolve(env, cursor, "_id")?,
snippet: ColumnIndex::resolve(env, cursor, "snippet")?,
msg_count: ColumnIndex::resolve(env, cursor, "msg_count")?,
recipient_ids: ColumnIndex::resolve(env, cursor, "recipient_ids")?,
date: ColumnIndex::resolve(env, cursor, "date")?,
})
}
}
fn list_threads_inner(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result<Vec<SmsThread>> { fn list_threads_inner(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result<Vec<SmsThread>> {
let resolver = env let resolver = env
.call_method( .call_method(
@ -52,98 +80,32 @@ fn list_threads_inner(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result<Ve
return Ok(Vec::new()); return Ok(Vec::new());
} }
let mut threads = Vec::new(); let guard = CursorGuard::new(cursor);
while env.call_method(&cursor, "moveToNext", "()Z", &[])?.z()? { let columns = match ThreadColumns::resolve(env, guard.as_obj()) {
let thread_id = get_i64_column(env, &cursor, "thread_id")? Ok(c) => c,
.or_else(|| get_i64_column(env, &cursor, "_id").ok().flatten()) Err(e) => {
clear_pending_exception(env);
guard.close(env);
return Err(e);
}
};
cursor::for_each_row(env, &guard, |env| {
let obj = guard.as_obj();
// The simple conversations view exposes _id; the full view
// exposes thread_id. Prefer thread_id, fall back to _id.
let thread_id = cursor::get_i64(env, obj, columns.thread_id)?
.or(cursor::get_i64(env, obj, columns.row_id)?)
.unwrap_or_default(); .unwrap_or_default();
threads.push(SmsThread { Ok(SmsThread {
thread_id, thread_id,
snippet: get_string_column(env, &cursor, "snippet")?, snippet: cursor::get_string(env, obj, columns.snippet)?,
message_count: get_i32_column(env, &cursor, "msg_count")?, message_count: cursor::get_i32(env, obj, columns.msg_count)?,
recipient_ids: get_string_column(env, &cursor, "recipient_ids")?, recipient_ids: cursor::get_string(env, obj, columns.recipient_ids)?,
date_ms: get_i64_column(env, &cursor, "date")?, date_ms: cursor::get_i64(env, obj, columns.date)?,
}); })
} })
let _ = env.call_method(&cursor, "close", "()V", &[]);
Ok(threads)
}
fn get_column_index(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Option<i32>> {
let jname = env.new_string(name)?;
let idx = env
.call_method(
cursor,
"getColumnIndex",
"(Ljava/lang/String;)I",
&[JValueGen::Object(&JObject::from(jname))],
)?
.i()?;
if idx < 0 {
Ok(None)
} else {
Ok(Some(idx))
}
}
fn is_null_at(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, idx: i32) -> Result<bool> {
env.call_method(cursor, "isNull", "(I)Z", &[JValueGen::Int(idx)])?
.z()
.map_err(|e| e.into())
}
fn get_i64_column(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Option<i64>> {
let Some(idx) = get_column_index(env, cursor, name)? else {
return Ok(None);
};
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
Ok(Some(
env.call_method(cursor, "getLong", "(I)J", &[JValueGen::Int(idx)])?
.j()?,
))
}
fn get_i32_column(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Option<i32>> {
let Some(idx) = get_column_index(env, cursor, name)? else {
return Ok(None);
};
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
Ok(Some(
env.call_method(cursor, "getInt", "(I)I", &[JValueGen::Int(idx)])?
.i()?,
))
}
fn get_string_column(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result<Option<String>> {
let Some(idx) = get_column_index(env, cursor, name)? else {
return Ok(None);
};
if is_null_at(env, cursor, idx)? {
return Ok(None);
}
let obj = env
.call_method(
cursor,
"getString",
"(I)Ljava/lang/String;",
&[JValueGen::Int(idx)],
)?
.l()?;
if obj.is_null() {
return Ok(None);
}
let jstr = JString::from(obj);
let string_val: String = env.get_string(&jstr)?.into();
Ok(Some(string_val))
} }