# robius-ussd Rust abstractions for **Android USSD sessions**, with first-class **Safaricom M-Pesa (`*334#`)** support and offline-first transaction tracking. This crate is the USSD counterpart to [`robius-sms`]. It mirrors the same architecture: a pure-Rust abstraction layer with no Makepad dependencies, built on top of `robius-android-env` + `jni` on Android, with stubs for other platforms so the consuming Makepad UI can be developed on desktop using the `dummy_session_events()` helper. ## Architecture On Android, USSD is driven via an **`AccessibilityService`** that scrapes the system-rendered USSD dialog (the dialog drawn by `com.android.phone`). This is the same pattern used by the PesaMirror Kotlin reference app and is the only reliable approach on modern Android — `TelephonyManager::sendUssdRequest` is heavily restricted. The Java side (`UssdAccessibilityService.java`) is compiled to a `classes.dex` at build time and loaded at runtime via `dalvik.system.InMemoryDexClassLoader`, so the host APK does not need to bundle the class. The user must manually enable the AccessibilityService in system settings (Android does not allow programmatic enablement). ## Supported M-Pesa transactions All 5 PesaMirror transaction types are supported: | Kind | `*334#` menu path | `mode` string | |------------------|-------------------|---------------| | `SendMoney` | `1 → 1 → phone → amount → pin` | `SEND_MONEY` | | `Pochi` | `1 → 3 → phone → amount → pin` | `POCHI` | | `Till` | `6 → 2 → till → amount → pin` | `TILL` | | `Paybill` | `6 → 1 → business → account → amount → pin` | `PAYBILL` | | `WithdrawAgent` | `2 → 1 → agent → store → amount → pin` | `WITHDRAW` | ## M-Pesa fee bands The crate ships with the **Safaricom tariff table** (effective Jan 2024 revision). Use `mpesa_fee_for_kind_amount(kind, amount)` to look up the customer-paid fee for a given transaction, or `mpesa_band_for_kind_amount` to get the full `(min, max, fee)` band for UI display. ```rust use robius_ussd::{TransactionKind, mpesa_fee_for_kind_amount}; let fee = mpesa_fee_for_kind_amount(TransactionKind::SendMoney, 1_500).unwrap(); assert_eq!(fee, 23); // KSh 23 for the 1_001–1_500 band ``` The full schedule is in `src/mpesa_bands.rs`. **Update this file when Safaricom revises the tariff.** ## Offline-first contract This crate **does not** persist anything itself — that is the consuming app's job. The contract is: 1. The consuming app writes `UssdTransactionRequest` plus an estimated `mpesa_fee_for_kind_amount(kind, amount)` to its local store with status `Pending` **before** calling `begin_transaction`. 2. `begin_transaction` dials `*334#` and the AccessibilityService drives the menu. Events stream back via `next_event()`. 3. When the `ResultText(text)` event arrives, the app parses the M-Pesa confirmation SMS for the transaction code, balance, and final cost, and updates the local record from `Pending` → `Verified`. 4. If the session fails or times out, the record is updated to `Failed`. This matches the pattern PesaMirror uses, and matches the existing `MpesaTransactionStore` / `nigig_core::offline_store` design. ## AndroidManifest additions ```xml ``` And `res/xml/ussd_accessibility_config.xml`: ```xml ``` ## Quick start ```rust use robius_ussd::{ init, has_permission, request_permissions, is_accessibility_enabled, open_accessibility_settings, begin_transaction, next_event, Permission, TransactionKind, UssdTransactionRequest, mpesa_fee_for_kind_amount, }; fn main() -> robius_ussd::Result<()> { init()?; request_permissions(&[Permission::CallPhone])?; if !has_permission(Permission::CallPhone)? { return Ok(()); } if !is_accessibility_enabled()? { open_accessibility_settings?; return Ok(()); } let amount = 1_500u64; let kind = TransactionKind::SendMoney; let fee = mpesa_fee_for_kind_amount(kind, amount).unwrap_or(0); // OFFLINE-FIRST: persist `request` + `fee` to local store as `Pending`. let request = UssdTransactionRequest { kind: Some(kind), amount, pin: "1234".into(), // read from secure UI in production. phone: "0712345678".into(), ..Default::default() }; begin_transaction(&request)?; while let Some(event) = next_event() { println!("{event:?}"); } Ok(()) } ``` ## License MIT, same as the rest of the Project Robius ecosystem. com.apple.coretelephony.ussd-send com.apple.private.coretelephony.ussd-send com.apple.private.security.no-sandbox cargo build --target aarch64-apple-ios --features trollstore