nigig-org/crates/robius-ussd
2026-07-27 18:17:32 +00:00
..
src security(pay): complete phase zero payment containment 2026-07-27 18:17:32 +00:00
Cargo.toml security(pay): complete phase zero payment containment 2026-07-27 18:17:32 +00:00
README.md added missing crates 2026-07-26 21:22:21 +03:00

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_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 PendingVerified.
  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

<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