163 lines
5.9 KiB
Markdown
163 lines
5.9 KiB
Markdown
# 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
|
||
<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
|
||
|