nigig-org/crates/robius-ussd/README.md
2026-07-26 21:22:21 +03:00

163 lines
5.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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_0011_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
<manifest ...>
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application ...>
<service
android:name="robius.ussd.UssdAccessibilityService"
android:exported="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/ussd_accessibility_config" />
</service>
</application>
</manifest>
```
And `res/xml/ussd_accessibility_config.xml`:
```xml
<accessibility-service
xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault|flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:packageNames="com.android.phone,com.google.android.dialer,com.android.dialer,com.samsung.android.dialer" />
```
## 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.
<key>com.apple.coretelephony.ussd-send</key>
<true/>
<key>com.apple.private.coretelephony.ussd-send</key>
<true/>
<key>com.apple.private.security.no-sandbox</key>
<true/>
cargo build --target aarch64-apple-ios --features trollstore