# 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