Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

<uses-feature android:name="android.hardware.camera" android:required="false" />
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/offpay/app/OffPayApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.offpay.app.data.AppDatabase
import com.offpay.app.data.ContactRepository
import com.offpay.app.data.HistoryRepository
import com.offpay.app.data.PreferencesRepository
import com.offpay.app.domain.ActionRunner
Expand Down Expand Up @@ -37,6 +38,9 @@ class OffPayApplication : Application() {
lateinit var prefsRepo: PreferencesRepository
private set

lateinit var contactRepo: ContactRepository
private set

// ─── Platform Layer ────────────────────────────────────────────────────────

lateinit var overlayController: OverlayControllerImpl
Expand Down Expand Up @@ -64,6 +68,7 @@ class OffPayApplication : Application() {
database = AppDatabase.create(this, passphrase)
historyRepo = HistoryRepository(database.transactionDao())
prefsRepo = PreferencesRepository(dataStore)
contactRepo = ContactRepository(this)

// Platform layer
overlayController = OverlayControllerImpl(this)
Expand Down
48 changes: 48 additions & 0 deletions app/src/main/java/com/offpay/app/data/ContactRepository.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.offpay.app.data

import android.content.ContentResolver
import android.content.Context
import android.provider.ContactsContract
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

data class Contact(
val name: String,
val phoneNumber: String
)

class ContactRepository(private val context: Context) {

suspend fun fetchContacts(): List<Contact> = withContext(Dispatchers.IO) {
val contactList = mutableListOf<Contact>()
val contentResolver: ContentResolver = context.contentResolver
val cursor = contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
arrayOf(
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER
),
null,
null,
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME + " ASC"
)

cursor?.use {
val nameIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
val numberIndex = it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)

while (it.moveToNext()) {
val name = it.getString(nameIndex)
val number = it.getString(numberIndex).filter { char -> char.isDigit() }


if (number.length >= 10) {
val cleanedNumber = number.takeLast(10)
contactList.add(Contact(name, cleanedNumber))
}
}
}

contactList.distinctBy { it.phoneNumber }
}
}
40 changes: 40 additions & 0 deletions app/src/main/java/com/offpay/app/data/PreferencesRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.offpay.app.domain.OperationMode
Expand All @@ -16,10 +17,49 @@ object PreferencesKeys {
val FIRST_LAUNCH_COMPLETE = booleanPreferencesKey("first_launch_complete")
val LAST_BALANCE_TEXT = stringPreferencesKey("last_balance_text")
val LAST_BALANCE_TIMESTAMP = longPreferencesKey("last_balance_timestamp")
val UPI_PIN_LENGTH = intPreferencesKey("upi_pin_length")
val DEFAULT_SIM_SLOT = intPreferencesKey("default_sim_slot")
val SELECTED_SIM_CARRIER = stringPreferencesKey("selected_sim_carrier")
val LAST_KNOWN_SIM_IDS = stringPreferencesKey("last_known_sim_ids")
}

class PreferencesRepository(private val dataStore: DataStore<Preferences>) {

val selectedSimCarrier: Flow<String?> = dataStore.data.map { it[PreferencesKeys.SELECTED_SIM_CARRIER] }

suspend fun setSelectedSimCarrier(name: String?) {
dataStore.edit { prefs ->
if (name == null) prefs.remove(PreferencesKeys.SELECTED_SIM_CARRIER)
else prefs[PreferencesKeys.SELECTED_SIM_CARRIER] = name
}
}

val lastKnownSimIds: Flow<String?> = dataStore.data.map { it[PreferencesKeys.LAST_KNOWN_SIM_IDS] }

suspend fun setLastKnownSimIds(ids: String) {
dataStore.edit { it[PreferencesKeys.LAST_KNOWN_SIM_IDS] = ids }
}

val upiPinLength: Flow<Int> = dataStore.data.map { preferences ->
preferences[PreferencesKeys.UPI_PIN_LENGTH] ?: 6
}

suspend fun setUpiPinLength(length: Int) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.UPI_PIN_LENGTH] = length
}
}

val defaultSimSlot: Flow<Int> = dataStore.data.map { preferences ->
preferences[PreferencesKeys.DEFAULT_SIM_SLOT] ?: -1
}

suspend fun setDefaultSimSlot(slot: Int) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.DEFAULT_SIM_SLOT] = slot
}
}

val operationMode: Flow<OperationMode> = dataStore.data.map { preferences ->
val stored = preferences[PreferencesKeys.OPERATION_MODE]
if (stored != null) {
Expand Down
35 changes: 31 additions & 4 deletions app/src/main/java/com/offpay/app/domain/ActionRunner.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ data class ActionRun(
class ActionRunner(private val engine: UssdEnginePort) {

companion object {
const val DEFAULT_PACING_DELAY_MS = 250L
const val DEFAULT_PACING_DELAY_MS = 50L

/**
* Universal success patterns. Checked BEFORE step matching on every
Expand All @@ -57,9 +57,22 @@ class ActionRunner(private val engine: UssdEnginePort) {
Regex("transaction\\s+successful", RegexOption.IGNORE_CASE),
Regex("txn\\s+successful", RegexOption.IGNORE_CASE),
Regex("payment\\s+successful", RegexOption.IGNORE_CASE),
Regex("request\\s+is\\s+being\\s+processed", RegexOption.IGNORE_CASE),
// A real referenceId in the text is a strong success signal
Regex("ref(?:erence)?\\s*(?:id|no|number|#)?\\s*[:\\-]?\\s*\\d{6,}", RegexOption.IGNORE_CASE)
)

/**
* Transient "loading" patterns. Frames matching these are ignored
* to prevent "Unexpected carrier response" during transient network states.
*/
private val TRANSIENT_PATTERNS: List<Regex> = listOf(
Regex("requesting", RegexOption.IGNORE_CASE),
Regex("processing", RegexOption.IGNORE_CASE),
Regex("please\\s+wait", RegexOption.IGNORE_CASE),
Regex("sending", RegexOption.IGNORE_CASE),
Regex("ussd\\s+code\\s+running", RegexOption.IGNORE_CASE)
)
}

/**
Expand Down Expand Up @@ -97,8 +110,9 @@ class ActionRunner(private val engine: UssdEnginePort) {

try {
// 1. Dismiss any leftover dialog and dial the action code.
val dialedCode = fillTemplate(action.code, vars)
engine.dismissDialog()
engine.dial(action.code)
engine.dial(dialedCode)

// 2. Capture our session ID. Frames with a different
// sessionId are leftovers from a prior run and discarded.
Expand Down Expand Up @@ -140,8 +154,8 @@ class ActionRunner(private val engine: UssdEnginePort) {
val step = action.steps[matchedIndex]
currentStepIndex = matchedIndex + 1

eventFlow.emit(ActionEvent.Progress(matchedIndex, totalSteps, step.label))
eventFlow.emit(ActionEvent.Frame(frame, matchedIndex))
eventFlow.emit(ActionEvent.Progress(matchedIndex, totalSteps, step.label))

if (step.done) {
terminated = true
Expand Down Expand Up @@ -171,14 +185,24 @@ class ActionRunner(private val engine: UssdEnginePort) {
// progression instead of a slot-machine autofill.
delay(step.delayMs)

engine.sendReply(reply)
if (step.autoSubmit) {
engine.sendReply(reply)
} else {
engine.fillReply(reply)
}

eventFlow.emit(ActionEvent.Reply(reply, matchedIndex))
}
return@collect
}

// ── Priority 4: terminal frame with no match ───────────
if (frame.isTerminal) {
// Ignore transient "loading" frames to wait for the final message.
if (matchesPattern(text, TRANSIENT_PATTERNS)) {
return@collect
}

terminated = true
eventFlow.emit(ActionEvent.Error("Unexpected carrier response", text))
resultDeferred.complete(ActionResult(success = false, resultText = text))
Expand Down Expand Up @@ -247,4 +271,7 @@ class ActionRunner(private val engine: UssdEnginePort) {

fun matchesFailurePattern(text: String, patterns: List<Regex>): Boolean =
patterns.any { it.containsMatchIn(text) }

private fun matchesPattern(text: String, patterns: List<Regex>): Boolean =
patterns.any { it.containsMatchIn(text) }
}
82 changes: 75 additions & 7 deletions app/src/main/java/com/offpay/app/domain/Actions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ object Actions {
Regex("psp\\s+(is\\s+)?not\\s+(registered|recognised|recognized)", RegexOption.IGNORE_CASE),
Regex("vpa\\s+(does\\s+not\\s+exist|is\\s+not\\s+(registered|valid))", RegexOption.IGNORE_CASE),
Regex("upi\\s*id\\s+(is\\s+)?(invalid|incorrect|wrong)", RegexOption.IGNORE_CASE),
Regex("merchant\\s+error|payee\\s+psp|payee\\s+not\\s+found", RegexOption.IGNORE_CASE),

// Account / user not found
Regex("(no|not\\s+a)\\s+(account|user|customer)\\s+(found|registered|exists)", RegexOption.IGNORE_CASE),
Expand All @@ -71,6 +72,9 @@ object Actions {
Regex("service\\s+(unavailable|not\\s+available|down)", RegexOption.IGNORE_CASE),
Regex("try\\s+again\\s+later|temporarily\\s+unavailable", RegexOption.IGNORE_CASE),
Regex("session\\s+(timed\\s+out|expired|terminated)", RegexOption.IGNORE_CASE),
Regex("not\\s+able\\s+to\\s+raise\\s+a\\s+request|connect\\s+with\\s+your\\s+bank", RegexOption.IGNORE_CASE),
Regex("network\\s+problem|network\\s+busy|connection\\s+problem|timed\\s+out", RegexOption.IGNORE_CASE),
Regex("cannot\\s+be\\s+processed|try\\s+after\\s+some\\s+time", RegexOption.IGNORE_CASE),

// *99# user-not-onboarded phrases — the carrier returns these when
// the user's mobile number isn't linked to a bank account for *99#.
Expand All @@ -81,6 +85,56 @@ object Actions {
Regex("bank\\s+not\\s+found|no\\s+bank\\s+(linked|found)", RegexOption.IGNORE_CASE)
)

val SendToMobile = Action(
code = "*99*1*1*{mobileNumber}#",
steps = listOf(
ActionStep(
match = Regex("(mobile\\s*number|mobile|msisdn|enter\\s+number|enter\\s+mobile)", RegexOption.IGNORE_CASE),
reply = "{mobileNumber}",
label = "Sending mobile number",
delayMs = 0L
),

ActionStep(
match = Regex("(enter\\s+amount|amount\\s+in\\s+rs|\\bamount\\b|\\bamt\\b)", RegexOption.IGNORE_CASE),
reply = "{amount}",
label = "Sending amount",
autoSubmit = false, // STOP HERE: Show Payee/Amount info
delayMs = 0L
),
ActionStep(
match = Regex("\\b(remark|comment|note)\\b", RegexOption.IGNORE_CASE),
reply = "{note}",
label = "Adding note",
delayMs = 0L
),
ActionStep(
match = Regex(
"\\bupi\\s*pin\\b|\\b(enter|6\\s*digit).*pin\\b",
RegexOption.IGNORE_CASE
),
reply = "{pin}",
label = "Entering UPI PIN",
delayMs = 0L
),
ActionStep(
match = Regex("\\b(confirm|press\\s*1|are you sure)\\b", RegexOption.IGNORE_CASE),
reply = "1",
label = "Confirming",
delayMs = 0L
),
ActionStep(
match = Regex(
"successful|payment\\s+(?:sent|completed|done)|thank\\s*you\\s*for\\s*using|reference\\s+(?:no|number|id)\\s*[:\\-]",
RegexOption.IGNORE_CASE
),
done = true,
label = "Payment complete"
)
),
failurePatterns = COMMON_FAILURES,
timeoutMs = 90_000L
)
/**
* Send money via UPI using *99*1*3#.
* 6-step flow: VPA → Amount → Remark → PIN → Confirm → Success.
Expand All @@ -94,27 +148,41 @@ object Actions {
// collide with "Enter amount" and "Enter UPI PIN".
match = Regex("(receiver|payee|recipient|vpa|virtual.*payment|upi.*id)", RegexOption.IGNORE_CASE),
reply = "{vpa}",
label = "Sending UPI ID"
label = "Sending UPI ID",
delayMs = 0L
),
// Optional: Merchant or Payee verification step that some carriers
// insert for business VPAs like DMRC or Blinkit.
ActionStep(
match = Regex("\\b(merchant|payee|verified|verify|continue|confirm|accept|proceed|yes|press\\s*1)\\b", RegexOption.IGNORE_CASE),
reply = "1",
label = "Verifying payee",
delayMs = 0L
),
ActionStep(
match = Regex("\\bamount\\b", RegexOption.IGNORE_CASE),
reply = "{amount}",
label = "Sending amount"
label = "Sending amount",
autoSubmit = false, // STOP HERE: Show Payee/Amount info
delayMs = 0L
),
ActionStep(
match = Regex("\\b(remark|comment|note)\\b", RegexOption.IGNORE_CASE),
reply = "{note}",
label = "Adding note"
label = "Adding note",
delayMs = 0L
),
ActionStep(
match = Regex("\\bupi\\s*pin\\b|\\b(enter|6\\s*digit).*pin\\b", RegexOption.IGNORE_CASE),
reply = "{pin}",
label = "Entering UPI PIN"
label = "Entering UPI PIN",
delayMs = 0L
),
ActionStep(
match = Regex("\\b(confirm|press\\s*1|are you sure)\\b", RegexOption.IGNORE_CASE),
reply = "1",
label = "Confirming"
label = "Confirming",
delayMs = 0L
),
ActionStep(
// Success terminal frame. Most success frames hit
Expand All @@ -126,7 +194,7 @@ object Actions {
)
),
failurePatterns = COMMON_FAILURES,
timeoutMs = 25_000L
timeoutMs = 90_000L
)

/**
Expand Down Expand Up @@ -157,6 +225,6 @@ object Actions {
Regex("max(imum)?\\s*(attempts|tries|retries)", RegexOption.IGNORE_CASE),
Regex("pin\\s*(blocked|locked|expired)", RegexOption.IGNORE_CASE)
),
timeoutMs = 18_000L
timeoutMs = 90_000L
)
}
14 changes: 13 additions & 1 deletion app/src/main/java/com/offpay/app/domain/InputValidator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ package com.offpay.app.domain
*/
object InputValidator {

private val VPA_PATTERN = Regex("^[a-zA-Z0-9._-]+@[a-zA-Z0-9]+$")
private val VPA_PATTERN = Regex("^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+$")
private const val VPA_MAX_LENGTH = 50
private const val AMOUNT_MIN = 1.0
private const val AMOUNT_MAX = 5000.0
private val PIN_PATTERN = Regex("^\\d{4,6}$")
private val DECIMAL_PLACES_PATTERN = Regex("^\\d+(\\.\\d{1,2})?$")
private val MOBILE_NUMBER_PATTERN = Regex("^[6-9]\\d{9}$")

/**
* Validates a UPI VPA (Virtual Payment Address).
Expand All @@ -32,6 +33,17 @@ object InputValidator {
return ValidationResult(isValid = true, errorMessage = null)
}

fun validateMobileNumber(mobileNumber: String): ValidationResult {
val trimmed = mobileNumber.trim()
if (trimmed.isEmpty()) {
return ValidationResult(isValid = false, errorMessage = "Mobile number is required")
}
if (!MOBILE_NUMBER_PATTERN.matches(trimmed)) {
return ValidationResult(isValid = false, errorMessage = "Enter a valid 10-digit mobile number")
}
return ValidationResult(isValid = true, errorMessage = null)
}

/**
* Validates a payment amount string.
* Must parse as a number between ₹1 and ₹5000 with at most 2 decimal places.
Expand Down
Loading