377 lines
16 KiB
Markdown
377 lines
16 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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]`:
|
|
|
|
```toml
|
|
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`:
|
|
|
|
```rust
|
|
// 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`:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```makepad
|
|
// 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`:**
|
|
|
|
```rust
|
|
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::<PaySheetAction>();
|
|
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`:
|
|
|
|
```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:
|
|
|
|
```rust
|
|
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 + From<jni::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 + From<jni::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:
|
|
|
|
```bash
|
|
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.
|