diff --git a/crates/apps/nigig-sms/src/sms_frame/bulk/sms_bulk_page.rs b/crates/apps/nigig-sms/src/sms_frame/bulk/sms_bulk_page.rs index a3abed6..9517322 100644 --- a/crates/apps/nigig-sms/src/sms_frame/bulk/sms_bulk_page.rs +++ b/crates/apps/nigig-sms/src/sms_frame/bulk/sms_bulk_page.rs @@ -272,12 +272,6 @@ pub struct SmsBulkPage { active_industry: String, #[rust] 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 { @@ -568,66 +562,18 @@ impl SmsBulkPage { let trimmed_body = body.trim(); if trimmed_body.is_empty() { self.view.label(cx, ids!(bulk_status)).set_text(cx, "Please type a message."); - self.pending_bulk_send = None; self.view.redraw(cx); return; } let recipients: Vec = recipients_text.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect(); if recipients.is_empty() { 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); 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 failed = 0; + let total = recipients.len(); for recipient in &recipients { match send_single(recipient, trimmed_body) { Ok(()) => sent += 1, diff --git a/crates/apps/nigig-sms/src/sms_frame/schedule/sms_schedule_page.rs b/crates/apps/nigig-sms/src/sms_frame/schedule/sms_schedule_page.rs index 6babcae..06523dd 100644 --- a/crates/apps/nigig-sms/src/sms_frame/schedule/sms_schedule_page.rs +++ b/crates/apps/nigig-sms/src/sms_frame/schedule/sms_schedule_page.rs @@ -17,10 +17,6 @@ use makepad_widgets::*; /// across app restarts, not just within one run. A read/write failure /// falls back to a timestamp-derived id: still unique in practice, and /// 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 { use std::sync::Mutex; use crate::chats::sms_frame::lock_ext::LockRecover; diff --git a/crates/robius-sms/README.md b/crates/robius-sms/README.md index 6b98079..b69aebb 100644 --- a/crates/robius-sms/README.md +++ b/crates/robius-sms/README.md @@ -24,12 +24,6 @@ Add these to your `AndroidManifest.xml`: - - - //! //! -//! -//! -//! //! //! //! //! ``` -//! -//! ## 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 sys; @@ -135,95 +111,6 @@ pub struct ScheduledMessage { pub interval_ms: Option, } -/// 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 { - 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::(); - (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 { /// Validate a schedule request. /// @@ -526,83 +413,4 @@ mod tests { 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); - } } diff --git a/crates/robius-sms/src/sys/android/cursor.rs b/crates/robius-sms/src/sys/android/cursor.rs deleted file mode 100644 index 05649d7..0000000 --- a/crates/robius-sms/src/sys/android/cursor.rs +++ /dev/null @@ -1,273 +0,0 @@ -// 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( - env: &mut JNIEnv<'_>, - f: impl FnOnce(&mut JNIEnv<'_>) -> Result, -) -> Result { - 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); - -impl ColumnIndex { - pub(crate) fn resolve(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result { - 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 { - self.0 - } -} - -fn is_null_at(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, idx: i32) -> Result { - 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> { - 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> { - 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> { - Ok(get_i32(env, cursor, col)?.map(|v| v != 0)) -} - -pub(crate) fn get_string( - env: &mut JNIEnv<'_>, - cursor: &JObject<'_>, - col: ColumnIndex, -) -> Result> { - 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( - env: &mut JNIEnv<'_>, - guard: &CursorGuard<'_>, - mut read_row: impl FnMut(&mut JNIEnv<'_>) -> Result, -) -> Result> { - 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) -} diff --git a/crates/robius-sms/src/sys/android/inbox.rs b/crates/robius-sms/src/sys/android/inbox.rs index 97245e9..defb820 100644 --- a/crates/robius-sms/src/sys/android/inbox.rs +++ b/crates/robius-sms/src/sys/android/inbox.rs @@ -1,16 +1,13 @@ use jni::{ - objects::{JObject, JValueGen}, + objects::{JObject, JString, JValueGen}, JNIEnv, }; -use super::cursor::{ - self, ColumnIndex, CursorGuard, clear_pending_exception, guarding_exceptions, -}; use crate::{Error, MessageKind, Result, SmsMessage}; pub(crate) fn list_messages(kind: Option) -> Result> { robius_android_env::with_activity(|env, activity| { - guarding_exceptions(env, |env| list_messages_inner(env, activity, kind, None)) + list_messages_inner(env, activity, kind, None) }) .map_err(|_| Error::AndroidEnvironment) .and_then(|x| x) @@ -18,45 +15,12 @@ pub(crate) fn list_messages(kind: Option) -> Result pub(crate) fn list_thread_messages(thread_id: i64) -> Result> { 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) .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 { - 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( env: &mut JNIEnv<'_>, activity: &JObject<'_>, @@ -123,40 +87,15 @@ fn list_messages_inner( return Ok(Vec::new()); } - // From here on the cursor is closed on every path, including `?`. - let guard = CursorGuard::new(cursor); + let mut messages = Vec::new(); - let columns = match SmsColumns::resolve(env, guard.as_obj()) { - Ok(c) => c, - Err(e) => { - clear_pending_exception(env); - guard.close(env); - return Err(e); - } - }; + while env.call_method(&cursor, "moveToNext", "()Z", &[])?.z()? { + messages.push(read_message_from_cursor(env, &cursor)?); + } - 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<'_>, - cursor: &JObject<'_>, - columns: &SmsColumns, -) -> Result { - 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)), - }) + Ok(messages) } fn uri_for_kind(kind: Option) -> &'static str { @@ -172,7 +111,119 @@ fn uri_for_kind(kind: Option) -> &'static str { } } -pub(crate) fn message_kind_from_android_type(value: i32) -> MessageKind { +fn read_message_from_cursor(env: &mut JNIEnv<'_>, cursor: &JObject<'_>) -> Result { + 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> { + 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 { + 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> { + 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> { + 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> { + Ok(get_i32_column(env, cursor, name)?.map(|v| v != 0)) +} + +fn get_string_column( + env: &mut JNIEnv<'_>, + cursor: &JObject<'_>, + name: &str, +) -> Result> { + 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 { 1 => MessageKind::Inbox, 2 => MessageKind::Sent, @@ -182,4 +233,4 @@ pub(crate) fn message_kind_from_android_type(value: i32) -> MessageKind { 6 => MessageKind::Queued, other => MessageKind::Unknown(other), } -} +} \ No newline at end of file diff --git a/crates/robius-sms/src/sys/android/mod.rs b/crates/robius-sms/src/sys/android/mod.rs index 5c10c2d..5d59e9e 100644 --- a/crates/robius-sms/src/sys/android/mod.rs +++ b/crates/robius-sms/src/sys/android/mod.rs @@ -1,4 +1,3 @@ -mod cursor; mod inbox; mod permissions; mod receivers; // Fixed: was receive diff --git a/crates/robius-sms/src/sys/android/send.rs b/crates/robius-sms/src/sys/android/send.rs index 86cd26f..2a9b9b9 100644 --- a/crates/robius-sms/src/sys/android/send.rs +++ b/crates/robius-sms/src/sys/android/send.rs @@ -25,20 +25,6 @@ pub(crate) fn send_bulk_sms(request: &BulkSendRequest) -> Result<()> { .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<()> { let sms_manager = env .call_static_method( @@ -52,50 +38,14 @@ pub(crate) fn send_text_message(env: &mut JNIEnv<'_>, recipient: &str, body: &st let destination = env.new_string(recipient)?; let text = env.new_string(body)?; - // ArrayList 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( - &sms_manager, - "sendTextMessage", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroid/app/PendingIntent;Landroid/app/PendingIntent;)V", - &[ - JValueGen::Object(&JObject::from(destination)), - JValueGen::Object(&JObject::null()), - JValueGen::Object(&JObject::from(text)), - JValueGen::Object(&JObject::null()), - JValueGen::Object(&JObject::null()), - ], - )?; - return Ok(()); - } - - // void sendMultipartTextMessage(String destinationAddress, - // String scAddress, - // ArrayList parts, - // ArrayList sentIntents, - // ArrayList deliveryIntents) env.call_method( &sms_manager, - "sendMultipartTextMessage", - "(Ljava/lang/String;Ljava/lang/String;Ljava/util/ArrayList;Ljava/util/ArrayList;Ljava/util/ArrayList;)V", + "sendTextMessage", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Landroid/app/PendingIntent;Landroid/app/PendingIntent;)V", &[ JValueGen::Object(&JObject::from(destination)), JValueGen::Object(&JObject::null()), - JValueGen::Object(&parts), + JValueGen::Object(&JObject::from(text)), JValueGen::Object(&JObject::null()), JValueGen::Object(&JObject::null()), ], diff --git a/crates/robius-sms/src/sys/android/thread.rs b/crates/robius-sms/src/sys/android/thread.rs index 22c74b2..31d16b3 100644 --- a/crates/robius-sms/src/sys/android/thread.rs +++ b/crates/robius-sms/src/sys/android/thread.rs @@ -1,42 +1,14 @@ use jni::{ - objects::{JObject, JValueGen}, + objects::{JObject, JString, JValueGen}, JNIEnv, }; -use super::cursor::{ - self, ColumnIndex, CursorGuard, clear_pending_exception, guarding_exceptions, -}; use crate::{Error, Result, SmsThread}; pub(crate) fn list_threads() -> Result> { - robius_android_env::with_activity(|env, activity| { - guarding_exceptions(env, |env| list_threads_inner(env, activity)) - }) - .map_err(|_| Error::AndroidEnvironment) - .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 { - 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")?, - }) - } + robius_android_env::with_activity(list_threads_inner) + .map_err(|_| Error::AndroidEnvironment) + .and_then(|x| x) } fn list_threads_inner(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result> { @@ -80,32 +52,98 @@ fn list_threads_inner(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result c, - 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)?) + while env.call_method(&cursor, "moveToNext", "()Z", &[])?.z()? { + let thread_id = get_i64_column(env, &cursor, "thread_id")? + .or_else(|| get_i64_column(env, &cursor, "_id").ok().flatten()) .unwrap_or_default(); - Ok(SmsThread { + threads.push(SmsThread { thread_id, - snippet: cursor::get_string(env, obj, columns.snippet)?, - message_count: cursor::get_i32(env, obj, columns.msg_count)?, - recipient_ids: cursor::get_string(env, obj, columns.recipient_ids)?, - date_ms: cursor::get_i64(env, obj, columns.date)?, - }) - }) + snippet: get_string_column(env, &cursor, "snippet")?, + message_count: get_i32_column(env, &cursor, "msg_count")?, + recipient_ids: get_string_column(env, &cursor, "recipient_ids")?, + date_ms: get_i64_column(env, &cursor, "date")?, + }); + } + + let _ = env.call_method(&cursor, "close", "()V", &[]); + + Ok(threads) } + +fn get_column_index(env: &mut JNIEnv<'_>, cursor: &JObject<'_>, name: &str) -> Result> { + 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 { + 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> { + 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> { + 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> { + 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)) +} \ No newline at end of file