| .. | ||
| src | ||
| Cargo.toml | ||
| README.md | ||
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.
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:
- The consuming app writes
UssdTransactionRequestplus an estimatedmpesa_fee_for_kind_amount(kind, amount)to its local store with statusPendingbefore callingbegin_transaction. begin_transactiondials*334#and the AccessibilityService drives the menu. Events stream back vianext_event().- 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 fromPending→Verified. - 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
<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:
<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
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