16 KiB
Integration Guide
This README explains how to wire the two new crates (robius-ussd,
robius-fingerprinting) and the Pay feature patch (nigig-mpesa-patches)
into the existing nigig-mpesa Makepad app.
1. Workspace layout
After integration, the workspace looks like:
your-workspace/
├── robius-sms/ ← existing (template)
├── robius-ussd/ ← NEW (copy from download/)
├── robius-fingerprinting/ ← NEW (copy from download/)
├── makepad/
├── robius/
│ ├── crates/
│ │ ├── location/
│ │ ├── directories/
│ │ └── open/
├── nigig-core/
├── nigig-uikit/
└── nigig-mpesa/
└── src/
└── pages/
└── pay/ ← NEW (copy from download/nigig-mpesa-patches/)
2. Copy the two new crates
cp -r /home/z/my-project/download/robius-ussd <your-workspace>/
cp -r /home/z/my-project/download/robius-fingerprinting <your-workspace>/
3. Copy the Pay feature into nigig-mpesa
cp -r /home/z/my-project/download/nigig-mpesa-patches/src/pages/pay \
<your-workspace>/nigig-mpesa/src/pages/
4. Wire nigig-mpesa/Cargo.toml
Add to [dependencies]:
robius-ussd = { path = "../../robius-ussd", features = ["serde"] }
robius-fingerprinting = { path = "../../robius-fingerprinting" }
Important: the serde feature on robius-ussd is REQUIRED — it enables
Serialize/Deserialize on TransactionKind, which PendingTransaction
needs for its JSON persistence. Without it you'll get "trait bound
TransactionKind: Serialize not satisfied" errors.
The existing deps robius-sms, nigig-core, nigig-uikit, makepad-widgets,
chrono, serde, serde_json are already declared.
5. Wire nigig-mpesa/src/lib.rs
In the existing lib.rs, register the pay module's script_mod:
// existing line: pub mod pages;
// existing line: pages::script_mod(vm);
// Add inside the existing script_mod function:
pages::pay::script_mod(vm);
And add a module declaration in src/pages/mod.rs:
pub mod pay;
// inside pages::script_mod(vm):
pay::script_mod(vm);
6. Wire the FAB + PaySheet into MpesaTrackerScreen
The existing MpesaTrackerScreen already has a flow: Overlay root with
main_content, transaction_detail_modal, modal_overlay, and
camera_widget children. Add two more children at the end:
// In src/pages/transactions/mod.rs inside the script_mod! block:
mod.widgets.MpesaTrackerScreen = #(MpesaTrackerScreen::register_widget(vm)) {
..mod.widgets.SolidView
width: Fill, height: Fill
flow: Overlay
show_bg: true
draw_bg.color: #xF8FAFC
main_content := SolidView { /* …existing… */ }
transaction_detail_modal := SolidView { /* …existing… */ }
modal_overlay := SolidView { /* …existing… */ }
camera_widget := mod.widgets.CameraWidget {}
// ── NEW: pay feature ──
pay_fab := mod.widgets.PayFab {
margin: Inset{right: 16, bottom: 16}
align: Align{x: 1.0, y: 1.0} // bottom-right anchor
}
pay_sheet := mod.widgets.PaySheet { visible: false }
}
The mod.widgets.PayFab and mod.widgets.PaySheet templates are defined in
src/pages/pay/pay_sheet.live — paste that file's contents into your
script_mod! block (Makepad 2.0 currently cannot load .live files across
crate boundaries, so the DSL must live inline).
7. Wire the event handler in MpesaTrackerScreen::handle_event
The PaySheet widget self-manages most of its event flow. The parent screen
only needs to react to FAB taps and final outcome actions. Add this import
at the top of src/pages/transactions/mod.rs:
use crate::pages::pay::{PaySheet, PaySheetAction, PaySheetTab};
use nigig_uikit::pull_up_sheet::PullUpSheetWidgetExt; // required for .pull_up_sheet()
Then in MpesaTrackerScreen::handle_event, inside the if let Event::Actions(actions) arm:
if self.view.button(cx, ids!(pay_fab)).clicked(&actions) { self.view.set_visible(cx, true); self.view.pull_up_sheet(cx, ids!(pay_sheet.sheet)).expand_full(cx); }
// Listen for PaySheet outcome actions.
// NOTE: cast::<T>() returns T directly (uses Default for failed casts),
// NOT Option<T>. So use match not if let Some(...).
for action in &actions {
let a = action.as_widget_action().cast::();
match &a {
PaySheetAction::Closed => {
self.view.set_visible(cx, false); // hide pay_sheet overlay
self.scan_sms(cx); // refresh transaction list
}
PaySheetAction::Verified(_, ) => {
self.scan_sms(cx); // pull in the new verified transaction
}
PaySheetAction::Failed(, _) => {
self.scan_sms(cx); // refresh in case a partial record exists
}
_ => {}
}
}
The full `PaySheetAction` enum is in `src/pages/pay/pay_sheet.rs`.
## 8. AndroidManifest additions
Merge these into the host app's `AndroidManifest.xml`:
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- ── robius-ussd ── -->
<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" />
<!-- ── robius-fingerprinting ── -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<application>
<!-- ── robius-ussd AccessibilityService ── -->
<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 create app/src/main/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" />
9. Initialisation
In the Makepad app's AppMain::handle_event, on Event::Construct, call:
fn handle_event(&mut self, cx: &mut Cx, event: &Event) {
match event {
Event::Construct => {
let _ = robius_ussd::init();
let _ = robius_fingerprinting::init();
}
_ => {}
}
// …existing event dispatch…
}
Both init() calls are idempotent and safe to call from any thread (they
internally use robius_android_env::with_activity).
10. End-to-end runtime flow
When the user taps the green "Pay" FAB:
PaySheet::open(cx)runs —PullUpSheet::expand_full(cx)slides the bottom sheet up.- User picks the Payment tab, chooses a kind (Send Money / Pochi / Till / Paybill / Withdraw), enters amount + recipient + PIN.
- As they type the amount,
refresh_band_previewcallsrobius_ussd::mpesa_band_for_kind_amount(kind, amount)and updates theband_preview_labelandfee_value_label. - User taps "Pay via M-Pesa" (green CTA).
PaySheet::start_payment_flow:- Validates the request.
- Writes a
PendingTransactiontopending_transactions.json(offline-first). - Calls
robius_fingerprinting::authenticatewithAuthPrompt::for_transaction.
- The Android system shows the BiometricPrompt.
- On
AuthResult::Succeeded:PaySheet::on_biometric_succeededcallsrobius_ussd::begin_transaction.- The Java
UssdAccessibilityServiceis invoked when the*334#dialog appears. - It drives the M-Pesa menu state machine (1→1, 1→3, 6→2, 6→1, 2→1) and types the user's inputs.
- On
UssdSessionEvent::ResultText(text):- The text is parsed by the existing
mpesa_parser::parse. TransactionFlow::on_sms_receivedmarks the pending recordVerifiedand upserts intoMpesaTransactionStore.- The transaction appears in the Transactions tab on the next SMS scan.
- The text is parsed by the existing
- On
UssdSessionEvent::SessionEnded{Failed}or verification-window expiry:- The pending record is marked
Failed. - The failure reason is shown in the
status_label.
- The pending record is marked
11. M-Pesa fee bands (the user's specific ask)
The Safaricom tariff table lives in robius-ussd/src/mpesa_bands.rs. It is
the Jan 2024 revision. There are six tables:
| Constant | Used for |
|---|---|
MPESA_SEND_REGISTERED |
Send Money → registered |
MPESA_SEND_UNREGISTERED |
Send Money → unregistered |
MPESA_POCHI |
Pochi la Biashara |
MPESA_TILL |
Lipa na M-Pesa (Till) |
MPESA_PAYBILL |
Pay Bill |
MPESA_WITHDRAW_AGENT |
Withdraw at Agent |
Update this file when Safaricom revises the tariff. The mpesa_band_for_kind_amount
function is the public API consumed by the Pay sheet's band_preview_label.
12. Transaction cost as Expense
The existing MpesaTransaction struct already has a cost: Option<f64>
field parsed from M-Pesa SMS ("Transaction Cost, KSh X.XX"). This patch
surfaces it via:
- The Pay sheet's
band_preview_labelshows the estimated cost BEFORE the transaction (from the band table). - After verification, the actual cost from the SMS is stored on the
MpesaTransaction.costfield. - The pending-store tracks
estimated_feevsactual_feefor discrepancy detection.
Future work (out of scope for this patch): add a "Fees paid" summary card
to the Expenses tab that aggregates tx.cost across all verified
transactions for a date range. The MpesaTransactionStore::totals
function already iterates all transactions; a new total_fees() method
is a 5-line addition.
13. File summary
| Path | Purpose |
|---|---|
robius-ussd/Cargo.toml |
Crate manifest |
robius-ussd/README.md |
Crate docs |
robius-ussd/src/lib.rs |
Public API (TransactionKind, UssdTransactionRequest, etc.) |
robius-ussd/src/error.rs |
Error enum |
robius-ussd/src/mpesa_bands.rs |
Safaricom fee band table |
robius-ussd/src/build.rs |
javac + d8 → classes.dex pipeline |
robius-ussd/src/sys/mod.rs |
cfg_if platform dispatch |
robius-ussd/src/sys/android/mod.rs |
Re-exports + Fromjni::errors::Error |
robius-ussd/src/sys/android/permissions.rs |
CALL_PHONE / POST_NOTIFICATIONS runtime perms |
robius-ussd/src/sys/android/dialer.rs |
tel:*334# via ACTION_CALL |
robius-ussd/src/sys/android/session.rs |
SharedPreferences-backed state machine |
robius-ussd/src/sys/android/accessibility.rs |
In-memory DEX + register_native_methods |
robius-ussd/src/sys/android/events.rs |
Java→Rust event channel |
robius-ussd/src/sys/android/UssdAccessibilityService.java |
Java AccessibilityService |
robius-ussd/src/examples/simple_ussd.rs |
Usage example |
robius-fingerprinting/Cargo.toml |
Crate manifest |
robius-fingerprinting/README.md |
Crate docs |
robius-fingerprinting/src/lib.rs |
Public API (AuthPrompt, AuthResult, etc.) |
robius-fingerprinting/src/error.rs |
Error enum |
robius-fingerprinting/src/build.rs |
javac + d8 → classes.dex pipeline |
robius-fingerprinting/src/sys/mod.rs |
cfg_if platform dispatch |
robius-fingerprinting/src/sys/android/mod.rs |
Re-exports + Fromjni::errors::Error |
robius-fingerprinting/src/sys/android/permissions.rs |
USE_BIOMETRIC / USE_FINGERPRINT perms |
robius-fingerprinting/src/sys/android/prompt.rs |
BiometricPrompt presentation + JNI bridge |
robius-fingerprinting/src/sys/android/events.rs |
Java→Rust event channel |
robius-fingerprinting/src/sys/android/BiometricCallback.java |
Java BiometricPrompt callback |
robius-fingerprinting/src/examples/simple_fingerprint.rs |
Usage example |
nigig-mpesa-patches/src/pages/pay/mod.rs |
Module registry + script_mod |
nigig-mpesa-patches/src/pages/pay/mpesa_bands_bridge.rs |
Re-exports + format_band_preview |
nigig-mpesa-patches/src/pages/pay/pay_sheet.rs |
PaySheet widget (FAB + bottom sheet + tabs) |
nigig-mpesa-patches/src/pages/pay/pay_sheet.live |
Makepad live DSL templates |
nigig-mpesa-patches/src/pages/pay/pending_store.rs |
Offline-first PendingTransactionStore |
nigig-mpesa-patches/src/pages/pay/transaction_flow.rs |
Offline-first state machine |
nigig-mpesa-patches/src/pages/pay/fingerprint_gate.rs |
Makepad action wrapper |
14. Testing
The robius-ussd crate has unit tests for the band table:
cd robius-ussd
cargo test
Expected output:
send_money_low_band_is_free✓send_money_mid_band✓send_money_high_band_caps_at_105✓till_is_always_free_for_customer✓withdraw_agent_minimum_is_50✓band_lookup_returns_band✓
The robius-fingerprinting crate has no unit tests (it's a JNI bridge —
the only meaningful test is on a real Android device). The dummy_success
/ dummy_failure helpers let the UI be developed on desktop.
The pending_store has a lifecycle test that runs without an Android device.
15. Known limitations / future work
- AccessibilityService enablement must be done manually by the user —
Android does not allow programmatic enablement. The crate detects this
via
is_accessibility_enabled()and the UI should prompt the user to open Settings. - M-Pesa PIN entry: in production, replace
pin_inputwith aSecureTextInputand never log the PIN. The PIN is currently held in memory only for the duration of the USSD session and written toSharedPreferencesby the Java side — it should be moved toEncryptedSharedPreferences(matching the PesaMirrorSecurePrefspattern). - Multi-session: only one transaction can be in flight at a time
(the
currentfield onTransactionFlow). This is intentional — M-Pesa itself does not support concurrent USSD sessions. - Tariff updates: when Safaricom revises the tariff, update
robius-ussd/src/mpesa_bands.rsonly — the consuming app picks up the change automatically. - Biometric crypto object: the current implementation initialises the
CryptoObject with a fixed
"robius-ussd-txn"plaintext. For real transaction signing, derive a per-transaction cipher from thePendingTransaction.idso a stolen prompt screenshot cannot be replayed.