nigig-org/crates/INTEGRATION_README.md
2026-07-26 19:38:26 +03:00

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:

  1. PaySheet::open(cx) runs — PullUpSheet::expand_full(cx) slides the bottom sheet up.
  2. User picks the Payment tab, chooses a kind (Send Money / Pochi / Till / Paybill / Withdraw), enters amount + recipient + PIN.
  3. As they type the amount, refresh_band_preview calls robius_ussd::mpesa_band_for_kind_amount(kind, amount) and updates the band_preview_label and fee_value_label.
  4. User taps "Pay via M-Pesa" (green CTA).
  5. PaySheet::start_payment_flow:
    • Validates the request.
    • Writes a PendingTransaction to pending_transactions.json (offline-first).
    • Calls robius_fingerprinting::authenticate with AuthPrompt::for_transaction.
  6. The Android system shows the BiometricPrompt.
  7. On AuthResult::Succeeded:
    • PaySheet::on_biometric_succeeded calls robius_ussd::begin_transaction.
    • The Java UssdAccessibilityService is 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.
  8. On UssdSessionEvent::ResultText(text):
    • The text is parsed by the existing mpesa_parser::parse.
    • TransactionFlow::on_sms_received marks the pending record Verified and upserts into MpesaTransactionStore.
    • The transaction appears in the Transactions tab on the next SMS scan.
  9. On UssdSessionEvent::SessionEnded{Failed} or verification-window expiry:
    • The pending record is marked Failed.
    • The failure reason is shown in the status_label.

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_label shows the estimated cost BEFORE the transaction (from the band table).
  • After verification, the actual cost from the SMS is stored on the MpesaTransaction.cost field.
  • The pending-store tracks estimated_fee vs actual_fee for 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

  1. 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.
  2. M-Pesa PIN entry: in production, replace pin_input with a SecureTextInput and never log the PIN. The PIN is currently held in memory only for the duration of the USSD session and written to SharedPreferences by the Java side — it should be moved to EncryptedSharedPreferences (matching the PesaMirror SecurePrefs pattern).
  3. Multi-session: only one transaction can be in flight at a time (the current field on TransactionFlow). This is intentional — M-Pesa itself does not support concurrent USSD sessions.
  4. Tariff updates: when Safaricom revises the tariff, update robius-ussd/src/mpesa_bands.rs only — the consuming app picks up the change automatically.
  5. 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 the PendingTransaction.id so a stolen prompt screenshot cannot be replayed.