diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ab86f60..280578c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
diff --git a/app/src/main/java/com/offpay/app/OffPayApplication.kt b/app/src/main/java/com/offpay/app/OffPayApplication.kt
index b44c955..61e6494 100644
--- a/app/src/main/java/com/offpay/app/OffPayApplication.kt
+++ b/app/src/main/java/com/offpay/app/OffPayApplication.kt
@@ -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
@@ -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
@@ -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)
diff --git a/app/src/main/java/com/offpay/app/data/ContactRepository.kt b/app/src/main/java/com/offpay/app/data/ContactRepository.kt
new file mode 100644
index 0000000..cf660d4
--- /dev/null
+++ b/app/src/main/java/com/offpay/app/data/ContactRepository.kt
@@ -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 = withContext(Dispatchers.IO) {
+ val contactList = mutableListOf()
+ 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 }
+ }
+}
diff --git a/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt b/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt
index 063743e..052f085 100644
--- a/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt
+++ b/app/src/main/java/com/offpay/app/data/PreferencesRepository.kt
@@ -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
@@ -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) {
+ val selectedSimCarrier: Flow = 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 = 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 = 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 = 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 = dataStore.data.map { preferences ->
val stored = preferences[PreferencesKeys.OPERATION_MODE]
if (stored != null) {
diff --git a/app/src/main/java/com/offpay/app/domain/ActionRunner.kt b/app/src/main/java/com/offpay/app/domain/ActionRunner.kt
index da4f92d..31a5428 100644
--- a/app/src/main/java/com/offpay/app/domain/ActionRunner.kt
+++ b/app/src/main/java/com/offpay/app/domain/ActionRunner.kt
@@ -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
@@ -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 = 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)
+ )
}
/**
@@ -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.
@@ -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
@@ -171,7 +185,12 @@ 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
@@ -179,6 +198,11 @@ class ActionRunner(private val engine: UssdEnginePort) {
// ── 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))
@@ -247,4 +271,7 @@ class ActionRunner(private val engine: UssdEnginePort) {
fun matchesFailurePattern(text: String, patterns: List): Boolean =
patterns.any { it.containsMatchIn(text) }
+
+ private fun matchesPattern(text: String, patterns: List): Boolean =
+ patterns.any { it.containsMatchIn(text) }
}
diff --git a/app/src/main/java/com/offpay/app/domain/Actions.kt b/app/src/main/java/com/offpay/app/domain/Actions.kt
index 52a7615..a03238a 100644
--- a/app/src/main/java/com/offpay/app/domain/Actions.kt
+++ b/app/src/main/java/com/offpay/app/domain/Actions.kt
@@ -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),
@@ -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#.
@@ -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.
@@ -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
@@ -126,7 +194,7 @@ object Actions {
)
),
failurePatterns = COMMON_FAILURES,
- timeoutMs = 25_000L
+ timeoutMs = 90_000L
)
/**
@@ -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
)
}
diff --git a/app/src/main/java/com/offpay/app/domain/InputValidator.kt b/app/src/main/java/com/offpay/app/domain/InputValidator.kt
index 6fc3314..ed591ec 100644
--- a/app/src/main/java/com/offpay/app/domain/InputValidator.kt
+++ b/app/src/main/java/com/offpay/app/domain/InputValidator.kt
@@ -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).
@@ -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.
diff --git a/app/src/main/java/com/offpay/app/domain/SessionState.kt b/app/src/main/java/com/offpay/app/domain/SessionState.kt
index 1a670d7..07d373d 100644
--- a/app/src/main/java/com/offpay/app/domain/SessionState.kt
+++ b/app/src/main/java/com/offpay/app/domain/SessionState.kt
@@ -5,7 +5,7 @@ package com.offpay.app.domain
*/
sealed class SessionState {
object Idle : SessionState()
- data class Running(val label: String, val stepIndex: Int, val total: Int) : SessionState()
+ data class Running(val label: String, val stepIndex: Int, val total: Int,val carrierText: String? = null) : SessionState()
data class Success(val resultText: String) : SessionState()
data class Failed(val message: String, val resultText: String) : SessionState()
}
diff --git a/app/src/main/java/com/offpay/app/domain/UpiParser.kt b/app/src/main/java/com/offpay/app/domain/UpiParser.kt
index 630cac9..e1f4e63 100644
--- a/app/src/main/java/com/offpay/app/domain/UpiParser.kt
+++ b/app/src/main/java/com/offpay/app/domain/UpiParser.kt
@@ -28,14 +28,45 @@ object UpiParser {
val vpa = params["pa"]?.let { decodeParam(it) } ?: return null
if (!isValidVpa(vpa)) return null
+ val rawAmount = params["am"]?.let { decodeParam(it) }
+ val sanitizedAmount = sanitizeAmount(rawAmount)
+
+ // Workaround for Dynamic QRs:
+ // Dynamic QRs use 'tr' (Transaction Ref) or 'tid' (Txn ID) to link the payment
+ // to an order. *99# doesn't have a dedicated field for these, but we can
+ // try passing them in the 'tn' (Note) field as a best-effort workaround.
+ // We limit to 20 chars as many USSD gateways truncate remarks anyway.
+ val note = (params["tn"] ?: params["tr"] ?: params["tid"])
+ ?.let { decodeParam(it) }
+ ?.take(20)
+
return UpiData(
vpa = vpa,
payeeName = params["pn"]?.let { decodeParam(it) },
- amount = params["am"]?.let { decodeParam(it) },
- transactionNote = params["tn"]?.let { decodeParam(it) }
+ amount = sanitizedAmount,
+ transactionNote = note
)
}
+ private fun sanitizeAmount(amount: String?): String? {
+ if (amount == null) return null
+ return try {
+ // Strip trailing .00 or .0 which often break USSD integer inputs
+ if (amount.contains(".")) {
+ val d = amount.toDouble()
+ if (d == d.toLong().toDouble()) {
+ d.toLong().toString()
+ } else {
+ "%.2f".format(d)
+ }
+ } else {
+ amount
+ }
+ } catch (_: Exception) {
+ amount
+ }
+ }
+
/**
* Validates whether the given string is a valid VPA format.
* Pattern: [a-zA-Z0-9.\-_]{3,}@[a-zA-Z0-9.\-_]{3,}
diff --git a/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt b/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt
index 55c030a..25d7c39 100644
--- a/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt
+++ b/app/src/main/java/com/offpay/app/domain/UssdEnginePort.kt
@@ -10,9 +10,16 @@ interface UssdEnginePort {
/** Dial a USSD code (e.g. "*99*1*3#") via ACTION_CALL intent. */
suspend fun dial(code: String)
+ /** Set the SIM to use for the next dial attempt, when the user chose one in-app. */
+ fun setPreferredSim(simInfo: SimInfo?)
+
/** Send a reply string to the active carrier dialog. Returns true if successful. */
suspend fun sendReply(reply: String): Boolean
+ suspend fun fillReply(reply: String): Boolean
+
+ suspend fun submitFilledReply(): Boolean
+
/** Cancel the active USSD session and dismiss the carrier dialog. */
suspend fun cancel()
diff --git a/app/src/main/java/com/offpay/app/domain/UssdModels.kt b/app/src/main/java/com/offpay/app/domain/UssdModels.kt
index 26304d3..1721dfa 100644
--- a/app/src/main/java/com/offpay/app/domain/UssdModels.kt
+++ b/app/src/main/java/com/offpay/app/domain/UssdModels.kt
@@ -30,7 +30,8 @@ data class ActionStep(
val reply: String? = null,
val done: Boolean = false,
val label: String? = null,
- val delayMs: Long = 250L
+ val delayMs: Long = 250L,
+ val autoSubmit: Boolean = true
)
/**
diff --git a/app/src/main/java/com/offpay/app/domain/Validation.kt b/app/src/main/java/com/offpay/app/domain/Validation.kt
index 7710a08..1d66ab1 100644
--- a/app/src/main/java/com/offpay/app/domain/Validation.kt
+++ b/app/src/main/java/com/offpay/app/domain/Validation.kt
@@ -15,4 +15,4 @@ data class FormValidationResult(val errors: Map)
/**
* Identifiers for the payment form fields.
*/
-enum class FormField { VPA, AMOUNT, PIN }
+enum class FormField { VPA, MOBILE_NUMBER, AMOUNT, PIN }
diff --git a/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt b/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt
index b58eedf..7ead5b9 100644
--- a/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt
+++ b/app/src/main/java/com/offpay/app/platform/CarrierDetector.kt
@@ -18,25 +18,25 @@ class CarrierDetector(private val context: Context) {
* Reads the active SIM's carrier information via SubscriptionManager/TelephonyManager.
* Returns null if no SIM is present or READ_PHONE_STATE permission is not granted.
*/
- suspend fun getActiveSimInfo(): SimInfo? = withContext(Dispatchers.IO) {
+ suspend fun getAvailableSims(): List = withContext(Dispatchers.IO) {
try {
val subscriptionManager =
context.getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as? SubscriptionManager
- ?: return@withContext fallbackFromTelephonyManager()
+ ?: return@withContext listOfNotNull(fallbackFromTelephonyManager())
val activeSubscriptions = try {
subscriptionManager.activeSubscriptionInfoList
} catch (e: SecurityException) {
// READ_PHONE_STATE not granted
- return@withContext fallbackFromTelephonyManager()
+ return@withContext listOfNotNull(fallbackFromTelephonyManager())
}
if (activeSubscriptions.isNullOrEmpty()) {
- return@withContext fallbackFromTelephonyManager()
+ return@withContext listOfNotNull(fallbackFromTelephonyManager())
}
+ activeSubscriptions.map { info ->
// Use the first active subscription (default data SIM)
- val info = activeSubscriptions[0]
SimInfo(
slotIndex = info.simSlotIndex,
subscriptionId = info.subscriptionId,
@@ -55,10 +55,12 @@ class CarrierDetector(private val context: Context) {
info.mnc.toString()
}
)
+ }
} catch (e: Exception) {
- fallbackFromTelephonyManager()
+ listOfNotNull(fallbackFromTelephonyManager())
}
}
+ suspend fun getActiveSimInfo(): SimInfo? = getAvailableSims().firstOrNull()
/**
* Fallback: use TelephonyManager when SubscriptionManager is unavailable.
diff --git a/app/src/main/java/com/offpay/app/platform/OverlayController.kt b/app/src/main/java/com/offpay/app/platform/OverlayController.kt
index 325393d..af4530f 100644
--- a/app/src/main/java/com/offpay/app/platform/OverlayController.kt
+++ b/app/src/main/java/com/offpay/app/platform/OverlayController.kt
@@ -43,6 +43,9 @@ interface OverlayController {
/** Callback invoked when the user taps cancel on the overlay. */
var onCancel: (() -> Unit)?
+ /** Callback invoked when the user taps send/confirm on the full overlay. */
+ var onConfirm: (() -> Unit)?
+
/** Callback invoked when the user taps the minimal bar (to bring app forward). */
var onMinimalTapped: (() -> Unit)?
}
diff --git a/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt b/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt
index adf3dc4..d9fbc1c 100644
--- a/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt
+++ b/app/src/main/java/com/offpay/app/platform/OverlayControllerImpl.kt
@@ -4,12 +4,14 @@ import android.content.Context
import android.graphics.Color
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
+import android.graphics.drawable.StateListDrawable
import android.os.Build
import android.os.Handler
import android.os.Looper
-import android.provider.Settings
+import android.util.DisplayMetrics
import android.util.TypedValue
import android.view.Gravity
+import android.view.HapticFeedbackConstants
import android.view.View
import android.view.WindowManager
import android.widget.Button
@@ -34,17 +36,16 @@ import android.widget.TextView
*/
class OverlayControllerImpl(private val context: Context) : OverlayController {
- private val windowManager: WindowManager =
- context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
-
private val handler = Handler(Looper.getMainLooper())
// ── Full overlay state ─────────────────────────────────────────────────────
- private var overlayView: FrameLayout? = null
+ private var overlayView: View? = null
private var titleView: TextView? = null
private var subtitleView: TextView? = null
private var stepLabelView: TextView? = null
private var spinnerView: ProgressBar? = null
+ private var sendButton: Button? = null
+ private var cancelButton: Button? = null
// ── Minimal floating bar state ─────────────────────────────────────────────
private var minimalView: LinearLayout? = null
@@ -54,18 +55,25 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
private var minimalProgressTrack: FrameLayout? = null
override var onCancel: (() -> Unit)? = null
+ override var onConfirm: (() -> Unit)? = null
+ set(value) {
+ field = value
+ runOnMain {
+ sendButton?.visibility = if (value != null) View.VISIBLE else View.GONE
+ }
+ }
override var onMinimalTapped: (() -> Unit)? = null
private fun fullParams(): WindowManager.LayoutParams {
- val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ val type = if (UssdAccessibilityService.instance != null) {
+ WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY
} else {
- @Suppress("DEPRECATION")
- WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
+ WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
}
+ val realHeight = getRealDisplayHeight()
return WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.MATCH_PARENT,
+ realHeight,
type,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
@@ -74,33 +82,33 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
+ windowAnimations = 0
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
+ }
}
}
- private fun minimalParams(): WindowManager.LayoutParams {
- val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
+ private fun getWindowManager(): WindowManager {
+ val service = UssdAccessibilityService.instance
+ return if (service != null) {
+ service.getSystemService(Context.WINDOW_SERVICE) as WindowManager
} else {
- @Suppress("DEPRECATION")
- WindowManager.LayoutParams.TYPE_SYSTEM_ALERT
- }
- return WindowManager.LayoutParams(
- WindowManager.LayoutParams.MATCH_PARENT,
- WindowManager.LayoutParams.WRAP_CONTENT,
- type,
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
- WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
- WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
- PixelFormat.TRANSLUCENT
- ).apply {
- gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
- y = dp(48)
+ context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
}
}
- override fun canShow(): Boolean = Settings.canDrawOverlays(context)
+ private fun getHostContext(): Context = UssdAccessibilityService.instance ?: context
+ override fun canShow(): Boolean = android.provider.Settings.canDrawOverlays(context)
+
+ private fun runOnMain(action: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ action()
+ } else {
+ handler.post(action)
+ }
+ }
// ─── Full overlay ──────────────────────────────────────────────────────────
override fun show(title: String, subtitle: String, stepLabel: String) {
@@ -108,28 +116,34 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
// If the minimal bar is up, dismiss it before opening the full overlay.
removeMinimalView()
- handler.post {
+ runOnMain {
if (overlayView != null) {
update(title, subtitle, stepLabel)
- return@post
+ return@runOnMain
}
try {
val v = buildFullView(title, subtitle, stepLabel)
- windowManager.addView(v, fullParams())
+ getWindowManager().addView(v, fullParams())
overlayView = v
- } catch (_: Exception) {
+ sendButton?.visibility = if (onConfirm != null) View.VISIBLE else View.GONE
+ } catch (e: Exception) {
overlayView = null
}
}
}
override fun update(title: String, subtitle: String, stepLabel: String) {
- handler.post {
+ runOnMain {
spinnerView?.visibility = View.VISIBLE
titleView?.setTextColor(NEOPOP_WHITE)
titleView?.text = title
subtitleView?.text = subtitle
stepLabelView?.text = stepLabel.uppercase()
+ sendButton?.visibility = if (onConfirm != null) View.VISIBLE else View.GONE
+
+ sendButton?.background = neoPopButtonBg(NEOPOP_ACCENT)
+ sendButton?.setTextColor(NEOPOP_ACCENT)
+ sendButton?.isClickable = true
}
}
@@ -140,52 +154,56 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
// If the full overlay is up, dismiss it before opening the minimal bar.
removeFullView()
- handler.post {
+ runOnMain {
if (minimalView != null) {
updateMinimal(progress, total, label)
- return@post
+ return@runOnMain
}
try {
val v = buildMinimalView(progress, total, label)
- windowManager.addView(v, minimalParams())
+ val params = WindowManager.LayoutParams(
+ WindowManager.LayoutParams.MATCH_PARENT,
+ WindowManager.LayoutParams.WRAP_CONTENT,
+ if (UssdAccessibilityService.instance != null) WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY else WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
+ WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
+ PixelFormat.TRANSLUCENT
+ ).apply {
+ gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
+ y = (48 * context.resources.displayMetrics.density).toInt()
+ }
+ getWindowManager().addView(v, params)
minimalView = v
- } catch (_: Exception) {
+ } catch (e: Exception) {
minimalView = null
}
}
}
override fun updateMinimal(progress: Int, total: Int, label: String) {
- handler.post {
- minimalLabel?.text = label.uppercase() + "…"
- minimalLabel?.setTextColor(NEOPOP_WHITE)
+ runOnMain {
+ minimalLabel?.text = label.uppercase() + "..."
val safeTotal = total.coerceAtLeast(1)
val displayedStep = (progress + 1).coerceIn(1, safeTotal)
minimalStepCount?.text = "STEP $displayedStep / $safeTotal"
- // Update progress fill width using weights/layout params.
minimalProgressTrack?.let { track ->
track.post {
val fraction = (progress.toFloat() / safeTotal).coerceIn(0f, 1f)
val newWidth = (track.width * fraction).toInt()
- minimalProgressFill?.layoutParams =
- FrameLayout.LayoutParams(
- newWidth,
- FrameLayout.LayoutParams.MATCH_PARENT
- )
+ minimalProgressFill?.layoutParams = FrameLayout.LayoutParams(newWidth, FrameLayout.LayoutParams.MATCH_PARENT)
}
}
}
}
override fun showError(title: String, message: String, holdMs: Long) {
- handler.post {
+ runOnMain {
// Prefer minimal bar feedback if it's already up.
if (minimalView != null) {
minimalLabel?.text = title.uppercase()
minimalLabel?.setTextColor(NEOPOP_DANGER)
minimalStepCount?.text = message.take(40)
handler.postDelayed({ hide() }, holdMs)
- return@post
+ return@runOnMain
}
if (overlayView == null) {
show(title, message, "")
@@ -200,7 +218,7 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
}
override fun hide() {
- handler.post {
+ runOnMain {
removeFullView()
removeMinimalView()
}
@@ -209,23 +227,23 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
private fun removeFullView() {
overlayView?.let { view ->
try {
- if (view.parent != null) windowManager.removeView(view)
- } catch (_: Exception) {
- }
+ getWindowManager().removeView(view)
+ } catch (e: Exception) {}
}
overlayView = null
titleView = null
subtitleView = null
stepLabelView = null
spinnerView = null
+ sendButton = null
+ cancelButton = null
}
private fun removeMinimalView() {
minimalView?.let { view ->
try {
- if (view.parent != null) windowManager.removeView(view)
- } catch (_: Exception) {
- }
+ getWindowManager().removeView(view)
+ } catch (e: Exception) {}
}
minimalView = null
minimalLabel = null
@@ -235,20 +253,34 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
}
// ─── Full overlay view construction ────────────────────────────────────────
+ private fun buildFullView(title: String, subtitle: String, stepLabel: String): View {
+ val host = getHostContext()
+ val realHeight = getRealDisplayHeight()
+ val statusBarHeight = getStatusBarHeight(host)
+
+ val root = FrameLayout(host).apply {
+ layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, realHeight)
+ setBackgroundColor(Color.TRANSPARENT)
+ }
- private fun buildFullView(title: String, subtitle: String, stepLabel: String): FrameLayout {
- val dim = FrameLayout(context).apply {
- setBackgroundColor(NEOPOP_DIM)
+ // The Shield: Solid body that anchors to the bottom and extends UP
+ // to the status bar line. This ensures the navigation bar area
+ // at the bottom is perfectly masked.
+ val shield = FrameLayout(host).apply {
+ val lp = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, realHeight - statusBarHeight)
+ lp.gravity = Gravity.BOTTOM
+ layoutParams = lp
+ setBackgroundColor(Color.BLACK)
isClickable = true
}
- val card = LinearLayout(context).apply {
+ val card = LinearLayout(host).apply {
orientation = LinearLayout.VERTICAL
background = neoPopCardBg()
setPadding(dp(24), dp(24), dp(24), dp(24))
}
- val brand = TextView(context).apply {
+ val brand = TextView(host).apply {
text = "OFFPAY"
setTextColor(NEOPOP_ACCENT)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
@@ -256,18 +288,18 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
- spinnerView = ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
+ spinnerView = ProgressBar(host, null, android.R.attr.progressBarStyleHorizontal).apply {
isIndeterminate = true
indeterminateTintList = android.content.res.ColorStateList.valueOf(NEOPOP_ACCENT)
val lp = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
- )
+ )
lp.topMargin = dp(20)
layoutParams = lp
}
- titleView = TextView(context).apply {
+ titleView = TextView(host).apply {
text = title
setTextColor(NEOPOP_WHITE)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
@@ -280,7 +312,7 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
layoutParams = lp
}
- subtitleView = TextView(context).apply {
+ subtitleView = TextView(host).apply {
text = subtitle
setTextColor(NEOPOP_TEXT_SECONDARY)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
@@ -292,7 +324,7 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
layoutParams = lp
}
- stepLabelView = TextView(context).apply {
+ stepLabelView = TextView(host).apply {
text = stepLabel.uppercase()
setTextColor(NEOPOP_ACCENT)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
@@ -306,177 +338,156 @@ class OverlayControllerImpl(private val context: Context) : OverlayController {
layoutParams = lp
}
- val cancel = Button(context).apply {
+ val actions = LinearLayout(host).apply {
+ orientation = LinearLayout.HORIZONTAL
+ gravity = Gravity.END
+ val lp = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
+ lp.topMargin = dp(20)
+ layoutParams = lp
+ }
+
+ val send = Button(host).apply {
+ text = "SEND"
+ setTextColor(NEOPOP_ACCENT)
+ background = neoPopButtonBg(NEOPOP_ACCENT)
+ setPadding(dp(20), dp(10), dp(20), dp(10))
+ isAllCaps = true
+ setOnClickListener {
+ it.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
+ it.background = GradientDrawable().apply { setColor(NEOPOP_ACCENT); cornerRadius = dp(4).toFloat() }
+ (it as Button).setTextColor(Color.BLACK)
+ it.isClickable = false
+ onConfirm?.invoke()
+ }
+ }
+ sendButton = send
+
+ val cancel = Button(host).apply {
text = "CANCEL"
setTextColor(NEOPOP_DANGER)
background = neoPopButtonBg(NEOPOP_DANGER)
setPadding(dp(20), dp(10), dp(20), dp(10))
isAllCaps = true
- letterSpacing = 0.1f
setOnClickListener {
+ it.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
onCancel?.invoke()
hide()
}
- val lp = LinearLayout.LayoutParams(
+ layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
- )
- lp.gravity = Gravity.END
- lp.topMargin = dp(20)
- layoutParams = lp
+ ).apply { leftMargin = dp(12) }
}
+ cancelButton = cancel
- card.addView(brand)
- card.addView(spinnerView)
- card.addView(titleView)
- card.addView(subtitleView)
- card.addView(stepLabelView)
- card.addView(cancel)
+ card.addView(brand); card.addView(spinnerView); card.addView(titleView); card.addView(subtitleView); card.addView(stepLabelView)
+ actions.addView(send); actions.addView(cancel); card.addView(actions)
- val cardLp = FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.MATCH_PARENT,
- FrameLayout.LayoutParams.WRAP_CONTENT
- ).apply {
+ val cardLp = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
- topMargin = dp(80)
- leftMargin = dp(20)
- rightMargin = dp(20)
+ topMargin = dp(120); leftMargin = dp(20); rightMargin = dp(20)
}
- dim.addView(card, cardLp)
- return dim
+ shield.addView(card, cardLp)
+ root.addView(shield)
+ return root
}
// ─── Minimal floating bar view construction ────────────────────────────────
private fun buildMinimalView(progress: Int, total: Int, label: String): LinearLayout {
- val safeTotal = total.coerceAtLeast(1)
- val displayedStep = (progress + 1).coerceIn(1, safeTotal)
-
- val container = LinearLayout(context).apply {
+ val host = getHostContext()
+ val container = LinearLayout(host).apply {
orientation = LinearLayout.VERTICAL
background = minimalBarBg()
- setPadding(dp(16), dp(12), dp(16), dp(0))
+ setPadding(dp(16), dp(12), dp(16), dp(10))
// 90% screen width — the WindowManager.LayoutParams.MATCH_PARENT
// along with horizontal margin handles this approximately.
- val sideMargin = dp(20)
- val lp = WindowManager.LayoutParams()
// Real margins are baked into params at attach time; here we just
// pad the inner view to leave a gutter.
- setPadding(dp(16) + sideMargin, dp(12), dp(16) + sideMargin, dp(0))
isClickable = true
- setOnClickListener {
- onMinimalTapped?.invoke()
- }
+ setOnClickListener { onMinimalTapped?.invoke() }
}
// Top row: spinner + label + step count
- val topRow = LinearLayout(context).apply {
+ val topRow = LinearLayout(host).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
- val lp = LinearLayout.LayoutParams(
+ layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
- layoutParams = lp
}
- val spinner = ProgressBar(context).apply {
+ val spinner = ProgressBar(host).apply {
indeterminateTintList = android.content.res.ColorStateList.valueOf(NEOPOP_ACCENT)
- val lp = LinearLayout.LayoutParams(dp(18), dp(18))
- lp.rightMargin = dp(10)
- layoutParams = lp
+ layoutParams = LinearLayout.LayoutParams(dp(18), dp(18)).apply { rightMargin = dp(10) }
}
- minimalLabel = TextView(context).apply {
- text = label.uppercase() + "…"
+ minimalLabel = TextView(host).apply {
+ text = label.uppercase() + "..."
setTextColor(NEOPOP_WHITE)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
- letterSpacing = 0.12f
typeface = android.graphics.Typeface.DEFAULT_BOLD
- val lp = LinearLayout.LayoutParams(
- 0,
- LinearLayout.LayoutParams.WRAP_CONTENT,
- 1f
- )
- layoutParams = lp
+ layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
- minimalStepCount = TextView(context).apply {
- text = "STEP $displayedStep / $safeTotal"
+ minimalStepCount = TextView(host).apply {
+ text = "STEP ${progress + 1} / $total"
setTextColor(NEOPOP_TEXT_SECONDARY)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 11f)
- letterSpacing = 0.1f
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
- topRow.addView(spinner)
- topRow.addView(minimalLabel)
- topRow.addView(minimalStepCount)
+ topRow.addView(spinner); topRow.addView(minimalLabel); topRow.addView(minimalStepCount)
// Bottom: 2dp progress track with lime fill
- minimalProgressTrack = FrameLayout(context).apply {
+ minimalProgressTrack = FrameLayout(host).apply {
setBackgroundColor(NEOPOP_BORDER)
- val lp = LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.MATCH_PARENT,
- dp(2)
- )
- lp.topMargin = dp(10)
- lp.bottomMargin = dp(10)
- layoutParams = lp
+ layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(2)).apply { topMargin = dp(10) }
}
- minimalProgressFill = View(context).apply {
+ minimalProgressFill = View(host).apply {
setBackgroundColor(NEOPOP_ACCENT)
- val fraction = (progress.toFloat() / safeTotal).coerceIn(0f, 1f)
- val lp = FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.MATCH_PARENT,
- FrameLayout.LayoutParams.MATCH_PARENT
- )
+ layoutParams = FrameLayout.LayoutParams(0, FrameLayout.LayoutParams.MATCH_PARENT)
// Initial pixel-width is a fudge; updateMinimal() recomputes it
// once the track has measured.
- lp.width = 0
- layoutParams = lp
}
minimalProgressTrack?.addView(minimalProgressFill)
- container.addView(topRow)
- container.addView(minimalProgressTrack)
+ container.addView(topRow); container.addView(minimalProgressTrack)
return container
}
- private fun minimalBarBg(): GradientDrawable {
- return GradientDrawable().apply {
- cornerRadius = dp(16).toFloat()
- setColor(NEOPOP_MINIMAL_FILL)
- setStroke(dp(1), NEOPOP_ACCENT)
- }
+ private fun getStatusBarHeight(c: Context): Int {
+ val resourceId = c.resources.getIdentifier("status_bar_height", "dimen", "android")
+ return if (resourceId > 0) c.resources.getDimensionPixelSize(resourceId) else dp(24)
}
- private fun neoPopCardBg(): GradientDrawable {
- return GradientDrawable().apply {
- setColor(NEOPOP_SURFACE_HIGH)
- setStroke(dp(1), NEOPOP_BORDER)
- }
+ private fun getRealDisplayHeight(): Int {
+ val wm = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
+ val display = wm.defaultDisplay
+ val metrics = DisplayMetrics()
+ display.getRealMetrics(metrics)
+ return metrics.heightPixels
}
- private fun neoPopButtonBg(stroke: Int): GradientDrawable {
- return GradientDrawable().apply {
- setColor(Color.TRANSPARENT)
- setStroke(dp(1), stroke)
- }
+ private fun minimalBarBg() = GradientDrawable().apply { cornerRadius = dp(16).toFloat(); setColor(NEOPOP_MINIMAL_FILL); setStroke(dp(1), NEOPOP_ACCENT) }
+ private fun neoPopCardBg() = GradientDrawable().apply { setColor(NEOPOP_SURFACE_HIGH); setStroke(dp(1), NEOPOP_BORDER) }
+ private fun neoPopButtonBg(stroke: Int) = StateListDrawable().apply {
+ addState(intArrayOf(android.R.attr.state_pressed), GradientDrawable().apply { setColor(stroke.withAlpha(0.15f)); setStroke(dp(2), stroke); cornerRadius = dp(4).toFloat() })
+ addState(intArrayOf(), GradientDrawable().apply { setColor(Color.TRANSPARENT); setStroke(dp(1), stroke); cornerRadius = dp(4).toFloat() })
}
- private fun dp(value: Int): Int =
- (value * context.resources.displayMetrics.density).toInt()
+ private fun Int.withAlpha(alpha: Float) = (this and 0x00FFFFFF) or ((alpha * 255).toInt().coerceIn(0, 255) shl 24)
+ private fun dp(value: Int) = (value * context.resources.displayMetrics.density).toInt()
companion object {
// NeoPOP palette (mirrors presentation/ui/theme/Colors.kt)
- private const val NEOPOP_DIM = 0xCC000000.toInt()
private const val NEOPOP_SURFACE_HIGH = 0xFF16181D.toInt()
private const val NEOPOP_BORDER = 0xFF2A2D34.toInt()
private const val NEOPOP_WHITE = 0xFFFFFFFF.toInt()
private const val NEOPOP_TEXT_SECONDARY = 0xFF9BA1A8.toInt()
private const val NEOPOP_ACCENT = 0xFFC5F542.toInt()
private const val NEOPOP_DANGER = 0xFFFF4D4D.toInt()
- private const val NEOPOP_MINIMAL_FILL = 0xEB000000.toInt() // ~92% opaque black
+ private const val NEOPOP_MINIMAL_FILL = 0xEB000000.toInt()
}
}
diff --git a/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt b/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt
index 4780e07..38b759d 100644
--- a/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt
+++ b/app/src/main/java/com/offpay/app/platform/QrScannerManager.kt
@@ -117,6 +117,9 @@ class QrScannerManager {
val clamped = ratio.coerceIn(MIN_ZOOM, MAX_ZOOM)
cameraControl?.setZoomRatio(clamped)
}
+ fun toggleTorch(enabled: Boolean) {
+ cameraControl?.enableTorch(enabled)
+ }
/**
* Unbinds all camera use cases and releases the camera.
diff --git a/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt b/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt
index b068b39..b7d4bd6 100644
--- a/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt
+++ b/app/src/main/java/com/offpay/app/platform/UssdAccessibilityService.kt
@@ -86,7 +86,7 @@ class UssdAccessibilityService : AccessibilityService() {
feedbackType = AccessibilityServiceInfo.FEEDBACK_GENERIC
flags = AccessibilityServiceInfo.FLAG_RETRIEVE_INTERACTIVE_WINDOWS or
AccessibilityServiceInfo.DEFAULT
- notificationTimeout = 100
+ notificationTimeout = 10
}
Log.d(TAG, "service connected")
}
@@ -137,6 +137,46 @@ class UssdAccessibilityService : AccessibilityService() {
*
* @return true if the reply was successfully sent
*/
+
+ fun fillReply(reply: String): Boolean {
+ val root = findUssdRoot() ?: return false
+ val edit = findEditText(root) ?: return false
+
+ val args = Bundle().apply {
+ putCharSequence(
+ AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
+ reply
+ )
+ }
+ val ok = edit.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)
+
+ lastEmittedText = null
+ frameFilter.reset()
+
+ return ok
+ }
+
+ fun submitFilledReply(): Boolean {
+ val root = findUssdRoot() ?: run {
+ Log.w(TAG, "submitFilledReply: no USSD window found")
+ return false
+ }
+ val edit = findEditText(root) ?: run {
+ Log.w(TAG, "submitFilledReply: no EditText in USSD window")
+ return false
+ }
+
+ lastEmittedText = null
+ frameFilter.reset()
+
+ val sendBtn = findClickableButton(root, SEND_LABELS)
+ val clicked = sendBtn?.performAction(AccessibilityNodeInfo.ACTION_CLICK) ?: false
+ if (!clicked) {
+ edit.performAction(AccessibilityNodeInfo.ACTION_CLICK)
+ }
+ return true
+ }
+
fun sendReply(reply: String): Boolean {
val root = findUssdRoot() ?: run {
Log.w(TAG, "sendReply: no USSD window found")
diff --git a/app/src/main/java/com/offpay/app/platform/UssdEngine.kt b/app/src/main/java/com/offpay/app/platform/UssdEngine.kt
index 8e23a0d..153ba9f 100644
--- a/app/src/main/java/com/offpay/app/platform/UssdEngine.kt
+++ b/app/src/main/java/com/offpay/app/platform/UssdEngine.kt
@@ -4,6 +4,9 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.SystemClock
+import android.telecom.PhoneAccountHandle
+import android.telecom.TelecomManager
+import com.offpay.app.domain.SimInfo
import com.offpay.app.domain.UssdEnginePort
import com.offpay.app.domain.UssdFrame
import kotlinx.coroutines.CoroutineScope
@@ -43,6 +46,9 @@ class UssdEngine(
/** Timestamp (elapsedRealtime) of the last dial() call for double-tap protection. */
private var lastDialTime: Long = 0L
+ @Volatile
+ private var preferredSim: SimInfo? = null
+
// ─── Coroutine Infrastructure ──────────────────────────────────────────────
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -62,9 +68,9 @@ class UssdEngine(
companion object {
const val DOUBLE_TAP_COOLDOWN_MS = 2_000L
- const val SLOW_WATCH_TIMEOUT_MS = 12_000L
- const val HARD_TIMEOUT_SEND_MS = 25_000L
- const val HARD_TIMEOUT_OTHER_MS = 30_000L
+ const val SLOW_WATCH_TIMEOUT_MS = 25_000L
+ const val HARD_TIMEOUT_SEND_MS = 90_000L
+ const val HARD_TIMEOUT_OTHER_MS = 90_000L
}
// ─── UssdEnginePort Implementation ─────────────────────────────────────────
@@ -79,7 +85,7 @@ class UssdEngine(
* 4. Cancel any in-flight timers, clear counters
* 5. Increment sessionId
* 6. sessionActive = true on engine and service
- * 7. Hide leftover overlay
+ * 7. Keep the current overlay visible
* 8. Register frame listener
* 9. Start timers
* 10. startActivity(ACTION_CALL)
@@ -117,7 +123,7 @@ class UssdEngine(
service?.sessionActive = true
// 7. Hide overlay from prior session
- overlayController?.hide()
+
// 8. Register as frame listener
service?.frameListener = this
@@ -131,10 +137,73 @@ class UssdEngine(
val callIntent = Intent(Intent.ACTION_CALL).apply {
data = Uri.parse("tel:$encodedCode")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ preferredSim?.let { sim ->
+
+ getPhoneAccountHandle(sim.subscriptionId)?.let { handle ->
+ putExtra(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, handle)
+ }
+
+
+ putExtra("com.android.phone.extra.slot", sim.slotIndex)
+ putExtra("slot", sim.slotIndex)
+ putExtra("simSlot", sim.slotIndex)
+ putExtra("subscription", sim.subscriptionId)
+ putExtra("subscription_id", sim.subscriptionId)
+ }
}
+
+
+ delay(100)
context.startActivity(callIntent)
}
+
+ private fun getPhoneAccountHandle(subscriptionId: Int): PhoneAccountHandle? {
+ if (subscriptionId == -1) return null
+ val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager
+ ?: return null
+
+ return try {
+ telecomManager.getCallCapablePhoneAccounts().find { handle ->
+ // In modern Android, the handle ID is usually the subscription ID string.
+ handle.id == subscriptionId.toString()
+ }
+ } catch (_: SecurityException) {
+ null
+ }
+ }
+
+ override fun setPreferredSim(simInfo: SimInfo?) {
+ preferredSim = simInfo
+ }
+
+
+ override suspend fun fillReply(reply: String): Boolean {
+ if (!sessionActive) return false
+
+ val service = UssdAccessibilityService.instance ?: return false
+ val success = service.fillReply(reply)
+
+ if (success) {
+ resetSlowWatch()
+ }
+
+ return success
+ }
+
+ override suspend fun submitFilledReply(): Boolean {
+ if (!sessionActive) return false
+
+ val service = UssdAccessibilityService.instance ?: return false
+ val success = service.submitFilledReply()
+
+ if (success) {
+ resetSlowWatch()
+ }
+
+ return success
+ }
+
/**
* Sends a reply to the active USSD carrier dialog.
* Resets the Slow_Watch timer on successful reply.
@@ -219,7 +288,7 @@ class UssdEngine(
slowWatchJob = scope.launch {
delay(SLOW_WATCH_TIMEOUT_MS)
if (sessionActive) {
- terminateSession("Carrier unresponsive — no activity for 12 seconds")
+ terminateSession("Carrier unresponsive — no activity for 1.5 minutes")
}
}
}
diff --git a/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt b/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt
index ba89e9d..57c83d9 100644
--- a/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt
+++ b/app/src/main/java/com/offpay/app/presentation/BalanceViewModel.kt
@@ -9,6 +9,9 @@ import com.offpay.app.domain.Actions
import com.offpay.app.domain.InputValidator
import com.offpay.app.domain.OperationMode
import com.offpay.app.domain.SessionState
+import com.offpay.app.domain.SimInfo
+import com.offpay.app.domain.UssdEnginePort
+import com.offpay.app.platform.CarrierDetector
import com.offpay.app.platform.OverlayController
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -17,6 +20,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@@ -35,7 +39,8 @@ data class BalanceResult(val text: String, val timestamp: Long)
data class BalanceUiState(
val pin: String = "",
val pinError: String? = null,
- val isSessionActive: Boolean = false
+ val isSessionActive: Boolean = false,
+ val simPickerTitle: String = "Choose SIM"
)
/**
@@ -56,6 +61,8 @@ data class BalanceUiState(
class BalanceViewModel(
private val actionRunner: ActionRunner,
private val prefsRepo: PreferencesRepository,
+ private val carrierDetector: CarrierDetector,
+ private val ussdEngine: UssdEnginePort,
private val overlayController: OverlayController? = null,
private val onDialerFallback: (String) -> Unit = {},
private val clipboardWriter: (String) -> Unit = {},
@@ -65,6 +72,7 @@ class BalanceViewModel(
*/
private val systemToast: (String) -> Unit = {}
) : ViewModel() {
+ private data class PendingBalance(val pin: String)
private val _uiState = MutableStateFlow(BalanceUiState())
val uiState: StateFlow = _uiState.asStateFlow()
@@ -75,6 +83,9 @@ class BalanceViewModel(
private val _snackbar = MutableStateFlow(null)
val snackbar: StateFlow = _snackbar.asStateFlow()
+ private val _simOptions = MutableStateFlow?>(null)
+ val simOptions: StateFlow?> = _simOptions.asStateFlow()
+
/**
* Last persisted balance result. Hydrated from DataStore so the screen
* renders the prior balance immediately on launch.
@@ -89,11 +100,86 @@ class BalanceViewModel(
val operationMode: StateFlow = prefsRepo.operationMode
.stateIn(viewModelScope, SharingStarted.Eagerly, OperationMode.AUTO)
+ val upiPinLength: StateFlow = prefsRepo.upiPinLength
+ .stateIn(viewModelScope, SharingStarted.Eagerly, 6)
+
+ val defaultSimSlot: StateFlow = prefsRepo.defaultSimSlot
+ .stateIn(viewModelScope, SharingStarted.Eagerly, -1)
+
private var sessionJob: Job? = null
+ private var pendingBalance: PendingBalance? = null
+
+ init {
+ // Shared logic with PayViewModel: pre-fetch only
+ viewModelScope.launch { carrierDetector.getAvailableSims() }
+ }
+
+ private fun getSimSignature(sims: List): String {
+ return sims.sortedBy { it.slotIndex }.joinToString("|") { "${it.slotIndex}:${it.carrierName ?: "Unknown"}" }
+ }
+
+ fun onSimSelected(simInfo: SimInfo) {
+ val currentTitle = _uiState.value.simPickerTitle
+ _simOptions.value = null
+ viewModelScope.launch {
+ if (currentTitle.contains("change", ignoreCase = true)) {
+ val sims = carrierDetector.getAvailableSims()
+ prefsRepo.setDefaultSimSlot(simInfo.slotIndex)
+ prefsRepo.setSelectedSimCarrier(simInfo.carrierName ?: "Unknown")
+ prefsRepo.setLastKnownSimIds(getSimSignature(sims))
+ }
+ val pending = pendingBalance
+ if (pending != null) {
+ pendingBalance = null
+ ussdEngine.setPreferredSim(simInfo)
+ runCheck(pending.pin)
+ }
+ }
+ }
+
+ fun onAskEveryTimeSelected() {
+ _simOptions.value = null
+ viewModelScope.launch {
+ val sims = carrierDetector.getAvailableSims()
+ prefsRepo.setDefaultSimSlot(-1)
+ prefsRepo.setSelectedSimCarrier(null)
+ prefsRepo.setLastKnownSimIds(getSimSignature(sims))
+ val pending = pendingBalance
+ if (pending != null) {
+ pendingBalance = null
+ _uiState.update { it.copy(simPickerTitle = "Choose SIM") }
+ _simOptions.value = sims
+ }
+ }
+ }
+
+ fun dismissSimSelection() { pendingBalance = null; _simOptions.value = null }
+
+ private suspend fun handleSimDetection(sims: List): SimInfo? {
+ val defaultSlot = prefsRepo.defaultSimSlot.first()
+ val selectedCarrier = prefsRepo.selectedSimCarrier.first()
+ val lastSig = prefsRepo.lastKnownSimIds.first()
+ val currentSig = getSimSignature(sims)
+
+ if (sims.size == 1) return sims.first()
+
+ if (defaultSlot != -1 && selectedCarrier != null) {
+ if (lastSig != currentSig) {
+ _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") }
+ return null
+ }
+ val currentInSlot = sims.find { it.slotIndex == defaultSlot }
+ if (currentInSlot != null && currentInSlot.carrierName == selectedCarrier) return currentInSlot
+ _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") }
+ return null
+ }
+ _uiState.update { it.copy(simPickerTitle = "Choose SIM") }
+ return null
+ }
- /** Inline PIN editing — strips non-digits, caps at 6, clears any error. */
fun onPinChanged(pin: String) {
- val digits = pin.filter { it.isDigit() }.take(6)
+ val maxLength = upiPinLength.value
+ val digits = pin.filter { it.isDigit() }.take(maxLength)
_uiState.update { it.copy(pin = digits, pinError = null) }
}
@@ -107,164 +193,63 @@ class BalanceViewModel(
*/
fun attemptCheckBalance() {
val mode = operationMode.value
+ if (mode == OperationMode.MANUAL) { onDialerFallback("*99*3#"); _sessionState.value = SessionState.Idle; return }
+ if (!actionRunner.isServiceEnabled()) { _sessionState.value = SessionState.Failed(message = "Accessibility service is disabled.", resultText = ""); return }
+ val validation = InputValidator.validatePin(_uiState.value.pin)
+ if (!validation.isValid) { _uiState.update { it.copy(pinError = validation.errorMessage) }; return }
+ maybeRequestSimThenRun(_uiState.value.pin)
+ }
- if (mode == OperationMode.MANUAL) {
- _snackbar.value = "Opening dialer for *99*3#"
- onDialerFallback("*99*3#")
- // System Toast — visible on top of the dialer so the user
- // remembers what's queued. The in-app snackbar above only
- // renders inside our activity which is about to lose focus.
- systemToast("Opening dialer for *99*3#")
- _sessionState.value = SessionState.Idle
- return
- }
-
- if (!actionRunner.isServiceEnabled()) {
- _sessionState.value = SessionState.Failed(
- message = "Accessibility service is disabled. Please enable it in Settings.",
- resultText = ""
- )
- return
- }
-
- val pin = _uiState.value.pin
- val validation = InputValidator.validatePin(pin)
- if (!validation.isValid) {
- _uiState.update { it.copy(pinError = validation.errorMessage) }
- return
+ private fun maybeRequestSimThenRun(pin: String) {
+ viewModelScope.launch {
+ val sims = carrierDetector.getAvailableSims()
+ if (sims.isEmpty()) { runCheck(pin); return@launch }
+ if (operationMode.value == OperationMode.MANUAL) { runCheck(pin); return@launch }
+ val targetSim = handleSimDetection(sims)
+ if (targetSim != null) { ussdEngine.setPreferredSim(targetSim); runCheck(pin) }
+ else { pendingBalance = PendingBalance(pin); _simOptions.value = sims }
}
-
- runCheck(pin)
}
- /**
- * Runs the CheckBalance action with the captured PIN.
- */
private fun runCheck(pin: String) {
- val mode = operationMode.value
-
_uiState.update { it.copy(pinError = null, isSessionActive = true) }
- _sessionState.value = SessionState.Running(
- label = "Checking balance",
- stepIndex = 0,
- total = Actions.CheckBalance.steps.size
- )
-
- when (mode) {
- OperationMode.AUTO -> overlayController?.show(
- title = "Checking balance",
- subtitle = "OffPay is asking your bank…",
- stepLabel = "STARTING"
- )
- OperationMode.ADVANCED -> overlayController?.showMinimal(
- progress = 0,
- total = Actions.CheckBalance.steps.size,
- label = "CHECKING BALANCE"
- )
- OperationMode.MANUAL -> Unit // not reached
+ _sessionState.value = SessionState.Running(label = "Checking balance", stepIndex = 0, total = Actions.CheckBalance.steps.size, carrierText = "Initializing...")
+ if (operationMode.value != OperationMode.MANUAL) {
+ overlayController?.show(title = "Checking balance", subtitle = "OffPay is asking your bank…", stepLabel = "STARTING")
}
overlayController?.onCancel = { cancelSession() }
-
val vars = mapOf("pin" to pin)
val actionRun = actionRunner.runAction(Actions.CheckBalance, vars, viewModelScope)
-
sessionJob = viewModelScope.launch {
launch {
+ var lastCarrierPrompt: String? = null
actionRun.events.collect { event ->
- // Drop any straggler events once we've already reached
- // a terminal session state (Success/Failed). Mirrors
- // the guard in PayViewModel and protects against a
- // synthetic "User cancelled" frame racing through
- // after engine.cancel() inside the runner.
- val current = _sessionState.value
- if (current is SessionState.Success || current is SessionState.Failed) {
- return@collect
- }
+ if (_sessionState.value !is SessionState.Running) return@collect
when (event) {
+ is ActionEvent.Frame -> lastCarrierPrompt = cleanCarrierText(event.frame.text)
is ActionEvent.Progress -> {
- _sessionState.value = SessionState.Running(
- label = event.label ?: "Processing",
- stepIndex = event.stepIndex,
- total = event.total
- )
- when (mode) {
- OperationMode.AUTO -> overlayController?.update(
- title = "Checking balance",
- subtitle = "OffPay is asking your bank…",
- stepLabel = (event.label ?: "Processing").uppercase()
- )
- OperationMode.ADVANCED -> overlayController?.updateMinimal(
- progress = event.stepIndex,
- total = event.total,
- label = "CHECKING BALANCE"
- )
- else -> Unit
- }
+ _sessionState.value = SessionState.Running(label = event.label ?: "Processing", stepIndex = event.stepIndex, total = event.total, carrierText = lastCarrierPrompt)
+ overlayController?.update(title = "Checking balance", subtitle = lastCarrierPrompt ?: "OffPay is asking your bank…", stepLabel = (event.label ?: "Processing").uppercase())
}
- is ActionEvent.Done -> {
- _sessionState.value = SessionState.Success(resultText = event.resultText)
- overlayController?.hide()
- launch {
- prefsRepo.setLastBalance(
- text = event.resultText,
- timestamp = System.currentTimeMillis()
- )
- }
- onSessionEnd()
- }
- is ActionEvent.Error -> {
- _sessionState.value = SessionState.Failed(
- message = event.message,
- resultText = event.resultText
- )
- overlayController?.showError(
- title = "Check failed",
- message = event.message
- )
- onSessionEnd()
- }
- else -> { /* Frame and Reply events — no UI state change */ }
+ is ActionEvent.Done -> { _sessionState.value = SessionState.Success(event.resultText); overlayController?.hide(); launch { prefsRepo.setLastBalance(event.resultText, System.currentTimeMillis()) }; onSessionEnd() }
+ is ActionEvent.Error -> { _sessionState.value = SessionState.Failed(event.message, event.resultText); overlayController?.showError("Check failed", event.message); onSessionEnd() }
+ else -> Unit
}
}
}
-
val result = actionRun.result.await()
- if (!result.success && _sessionState.value is SessionState.Running) {
- _sessionState.value = SessionState.Failed(
- message = "Balance check failed",
- resultText = result.resultText
- )
- onSessionEnd()
- }
+ if (!result.success && _sessionState.value is SessionState.Running) { _sessionState.value = SessionState.Failed("Balance check failed", result.resultText); onSessionEnd() }
}
}
- /**
- * Cancels the current balance check session.
- */
- fun cancelSession() {
- sessionJob?.cancel()
- sessionJob = null
- overlayController?.hide()
- _sessionState.value = SessionState.Idle
- onSessionEnd()
+ private fun cleanCarrierText(text: String): String {
+ val lines = text.split("\n").filter { it.isNotBlank() && !it.contains(Regex("^\\d+[.)]")) }
+ return if (lines.isEmpty()) text.trim() else lines.joinToString("\n").trim()
}
- fun dismissSession() {
- _sessionState.value = SessionState.Idle
- // Clear PIN so a retry forces fresh entry.
- _uiState.update { it.copy(pin = "") }
- }
-
- fun dismissSnackbar() {
- _snackbar.value = null
- }
-
- private fun onSessionEnd() {
- _uiState.update { it.copy(isSessionActive = false) }
- viewModelScope.launch {
- delay(500)
- _uiState.update { it.copy(pin = "") }
- }
- }
+ fun cancelSession() { sessionJob?.cancel(); sessionJob = null; overlayController?.hide(); _sessionState.value = SessionState.Idle; onSessionEnd() }
+ fun dismissSession() { _sessionState.value = SessionState.Idle; _uiState.update { it.copy(pin = "") } }
+ fun dismissSnackbar() { _snackbar.value = null }
+ override fun onCleared() { super.onCleared(); _uiState.update { it.copy(pin = "") } }
+ private fun onSessionEnd() { _uiState.update { it.copy(isSessionActive = false) }; pendingBalance = null; _simOptions.value = null; ussdEngine.setPreferredSim(null); viewModelScope.launch { delay(500); _uiState.update { it.copy(pin = "") } } }
}
diff --git a/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt b/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt
index 9dea25c..7e7aeef 100644
--- a/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt
+++ b/app/src/main/java/com/offpay/app/presentation/PayViewModel.kt
@@ -2,6 +2,8 @@ package com.offpay.app.presentation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.offpay.app.data.Contact
+import com.offpay.app.data.ContactRepository
import com.offpay.app.data.HistoryRepository
import com.offpay.app.data.PreferencesRepository
import com.offpay.app.domain.ActionEvent
@@ -12,7 +14,8 @@ import com.offpay.app.domain.FormField
import com.offpay.app.domain.InputValidator
import com.offpay.app.domain.OperationMode
import com.offpay.app.domain.SessionState
-import com.offpay.app.domain.UpiData
+import com.offpay.app.domain.SimInfo
+import com.offpay.app.domain.UssdEnginePort
import com.offpay.app.domain.UpiParser
import com.offpay.app.platform.CarrierDetector
import com.offpay.app.platform.OverlayController
@@ -22,22 +25,26 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-/**
- * UI state for the Pay screen form. PIN is captured inline on the form
- * itself in a highlighted "ENTER UPI PIN" section — no dedicated screen.
- */
+enum class PayTargetType { VPA, MOBILE_NUMBER }
+
data class PayUiState(
+ val targetType: PayTargetType = PayTargetType.VPA,
val vpa: String = "",
val payeeName: String = "",
val amount: String = "",
val note: String = "",
val pin: String = "",
+ val mobileNumber: String = "",
val errors: Map = emptyMap(),
- val isSessionActive: Boolean = false
+ val isSessionActive: Boolean = false,
+ val contacts: List = emptyList(),
+ val contactSearchQuery: String = "",
+ val simPickerTitle: String = "Choose SIM"
)
/**
@@ -62,7 +69,9 @@ class PayViewModel(
private val actionRunner: ActionRunner,
private val historyRepo: HistoryRepository,
private val prefsRepo: PreferencesRepository,
+ private val contactRepo: ContactRepository,
private val carrierDetector: CarrierDetector,
+ private val ussdEngine: UssdEnginePort,
private val overlayController: OverlayController? = null,
private val onDialerFallback: (String) -> Unit = {},
private val clipboardWriter: (String) -> Unit = {},
@@ -75,6 +84,7 @@ class PayViewModel(
*/
private val systemToast: (String) -> Unit = {}
) : ViewModel() {
+ private data class PendingPayment(val recipient: String, val amount: String, val note: String, val pin: String)
private val _uiState = MutableStateFlow(PayUiState())
val uiState: StateFlow = _uiState.asStateFlow()
@@ -85,339 +95,254 @@ class PayViewModel(
private val _snackbar = MutableStateFlow(null)
val snackbar: StateFlow = _snackbar.asStateFlow()
- /** Currently selected operation mode, observed for routing payments. */
+ private val _simOptions = MutableStateFlow?>(null)
+ val simOptions: StateFlow?> = _simOptions.asStateFlow()
+
val operationMode: StateFlow = prefsRepo.operationMode
.stateIn(viewModelScope, SharingStarted.Eagerly, OperationMode.AUTO)
+ val upiPinLength: StateFlow = prefsRepo.upiPinLength
+ .stateIn(viewModelScope, SharingStarted.Eagerly, 6)
+
+ val defaultSimSlot: StateFlow = prefsRepo.defaultSimSlot
+ .stateIn(viewModelScope, SharingStarted.Eagerly, -1)
+
private var activeRun: ActionRun? = null
private var sessionJob: Job? = null
+ private var pendingPayment: PendingPayment? = null
- /**
- * Re-prefills the form from a past transaction (used by "Pay again"
- * in the History screen). Drops any in-memory PIN so the user must
- * re-enter it; we never silently re-execute payments.
- */
- fun prefillFromTransaction(
- vpa: String,
- amount: String,
- note: String?
- ) {
- _uiState.update { current ->
- current.copy(
- vpa = vpa,
- amount = amount,
- note = note ?: "",
- pin = "",
- errors = emptyMap()
- )
+ init {
+ // Startup Hardware Sync
+ viewModelScope.launch {
+ val sims = carrierDetector.getAvailableSims()
+ if (sims.isEmpty()) return@launch
+
+ val defaultSlot = prefsRepo.defaultSimSlot.first()
+ val selectedCarrier = prefsRepo.selectedSimCarrier.first()
+ val currentSig = getSimSignature(sims)
+ val lastKnownSig = prefsRepo.lastKnownSimIds.first()
+
+ if (sims.size == 1) {
+ val onlySim = sims.first()
+ if (lastKnownSig != currentSig) {
+ _snackbar.value = "Using only available SIM (${onlySim.carrierName})"
+ prefsRepo.setDefaultSimSlot(onlySim.slotIndex)
+ prefsRepo.setSelectedSimCarrier(onlySim.carrierName ?: "Unknown")
+ prefsRepo.setLastKnownSimIds(currentSig)
+ }
+ return@launch
+ }
+
+ // If a default was set but hardware changed, ask immediately on startup.
+ if (defaultSlot != -1 && selectedCarrier != null && lastKnownSig != currentSig) {
+ _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") }
+ _simOptions.value = sims
+ }
}
}
- /**
- * Parses QR scanned data and autofills form fields for all non-null values.
- */
- fun onQrScanned(raw: String) {
- val upiData: UpiData = UpiParser.parse(raw) ?: return
- _uiState.update { current ->
- current.copy(
- vpa = upiData.vpa,
- payeeName = upiData.payeeName ?: current.payeeName,
- amount = upiData.amount ?: current.amount,
- note = upiData.transactionNote ?: current.note,
- errors = emptyMap()
- )
+ private fun getSimSignature(sims: List): String {
+ return sims.sortedBy { it.slotIndex }
+ .joinToString("|") { "${it.slotIndex}:${it.carrierName ?: "Unknown"}" }
+ }
+
+ fun onSimSelected(simInfo: SimInfo) {
+ val isHardwareChange = _uiState.value.simPickerTitle.contains("change")
+ _simOptions.value = null
+
+ viewModelScope.launch {
+ // A choice in the "SIM Change" dialog makes the SIM permanent.
+ if (isHardwareChange) {
+ val sims = carrierDetector.getAvailableSims()
+ prefsRepo.setDefaultSimSlot(simInfo.slotIndex)
+ prefsRepo.setSelectedSimCarrier(simInfo.carrierName ?: "Unknown")
+ prefsRepo.setLastKnownSimIds(getSimSignature(sims))
+ }
+
+ val pending = pendingPayment
+ if (pending != null) {
+ pendingPayment = null
+ ussdEngine.setPreferredSim(simInfo)
+ runPayment(pending.recipient, pending.amount, pending.note, pending.pin)
+ }
}
}
- /**
- * Updates one or more form fields. Pass `null` to leave a field unchanged.
- * Editing a field clears its validation error so the highlight goes away
- * as soon as the user starts fixing it.
- */
- fun onFormFieldChanged(
- vpa: String? = null,
- amount: String? = null,
- note: String? = null
- ) {
+ fun onAskEveryTimeSelected() {
+ _simOptions.value = null
+ viewModelScope.launch {
+ val sims = carrierDetector.getAvailableSims()
+ // Permanent choice to disable default
+ prefsRepo.setDefaultSimSlot(-1)
+ prefsRepo.setSelectedSimCarrier(null)
+ prefsRepo.setLastKnownSimIds(getSimSignature(sims))
+
+ val pending = pendingPayment
+ if (pending != null) {
+ pendingPayment = null
+ _uiState.update { it.copy(simPickerTitle = "Choose SIM") }
+ _simOptions.value = sims
+ }
+ }
+ }
+
+ fun dismissSimSelection() {
+ pendingPayment = null
+ _simOptions.value = null
+ }
+
+ private suspend fun handleSimDetection(sims: List): SimInfo? {
+ val defaultSlot = prefsRepo.defaultSimSlot.first()
+ val selectedCarrier = prefsRepo.selectedSimCarrier.first()
+ val lastSig = prefsRepo.lastKnownSimIds.first()
+ val currentSig = getSimSignature(sims)
+
+ if (sims.size == 1) return sims.first()
+
+ // Block transaction if hardware doesn't match saved signature
+ if (defaultSlot != -1 && selectedCarrier != null) {
+ if (lastSig != currentSig) {
+ _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") }
+ return null
+ }
+ val currentInSlot = sims.find { it.slotIndex == defaultSlot }
+ if (currentInSlot != null && currentInSlot.carrierName == selectedCarrier) {
+ return currentInSlot
+ } else {
+ _uiState.update { it.copy(simPickerTitle = "SIM change detected: Choose SIM") }
+ return null
+ }
+ }
+
+ _uiState.update { it.copy(simPickerTitle = "Choose SIM") }
+ return null
+ }
+
+ fun onTargetTypeChanged(targetType: PayTargetType) {
+ _uiState.update { it.copy(targetType = targetType, errors = it.errors - FormField.VPA - FormField.MOBILE_NUMBER) }
+ }
+
+ fun syncContacts() { viewModelScope.launch { _uiState.update { it.copy(contacts = contactRepo.fetchContacts()) } } }
+ fun onContactSearch(query: String) { _uiState.update { it.copy(contactSearchQuery = query) } }
+ fun onContactSelected(contact: Contact) { _uiState.update { it.copy(mobileNumber = contact.phoneNumber, contactSearchQuery = "") } }
+
+ fun prefillFromTransaction(vpa: String, amount: String, note: String?) {
+ _uiState.update { it.copy(vpa = vpa, amount = amount, note = note ?: "", pin = "", errors = emptyMap()) }
+ }
+
+ fun onQrScanned(raw: String) {
+ val upiData = UpiParser.parse(raw) ?: return
+ _uiState.update { it.copy(vpa = upiData.vpa, payeeName = upiData.payeeName ?: it.payeeName, amount = upiData.amount ?: it.amount, note = upiData.transactionNote ?: it.note, errors = emptyMap()) }
+ }
+
+ fun onFormFieldChanged(vpa: String? = null, amount: String? = null, mobileNumber: String? = null, note: String? = null) {
_uiState.update { current ->
val newErrors = current.errors.toMutableMap()
if (vpa != null) newErrors.remove(FormField.VPA)
if (amount != null) newErrors.remove(FormField.AMOUNT)
- current.copy(
- vpa = vpa ?: current.vpa,
- amount = amount ?: current.amount,
- note = note ?: current.note,
- errors = newErrors
- )
+ if (mobileNumber != null) newErrors.remove(FormField.MOBILE_NUMBER)
+ current.copy(vpa = vpa ?: current.vpa, amount = amount ?: current.amount, mobileNumber = mobileNumber ?: current.mobileNumber, note = note ?: current.note, errors = newErrors)
}
}
- /**
- * Inline PIN editing. Strips non-digits and caps at 6. Clears any
- * existing PIN error so the user sees the highlight clear as they type.
- */
fun onPinChanged(pin: String) {
- val digits = pin.filter { it.isDigit() }.take(6)
- _uiState.update { current ->
- current.copy(
- pin = digits,
- errors = current.errors - FormField.PIN
- )
- }
+ val maxLength = upiPinLength.value
+ val digits = pin.filter { it.isDigit() }.take(maxLength)
+ _uiState.update { it.copy(pin = digits, errors = it.errors - FormField.PIN) }
}
- /**
- * Attempts to start a payment. Validates the form, then either:
- * - Manual mode: copies VPA to clipboard, opens dialer, resets state.
- * - Advanced/Auto: runs the ActionRunner via [runPayment].
- *
- * Auto-fires once the user types a 6-digit PIN (the screen calls this).
- * Manual taps of the Pay button at 4-5 digits also reach here.
- */
fun attemptPayment() {
val state = _uiState.value
val mode = operationMode.value
-
- // For non-manual modes the accessibility service must be alive.
if (mode != OperationMode.MANUAL && !actionRunner.isServiceEnabled()) {
- _sessionState.value = SessionState.Failed(
- message = "Accessibility service is disabled. Enable it in Settings.",
- resultText = ""
- )
+ _sessionState.value = SessionState.Failed(message = "Accessibility service is disabled. Enable it in Settings.", resultText = "")
return
}
-
- // Validate VPA and amount up front for every mode (manual still
- // needs a valid VPA — that's what we copy to the clipboard).
val errors = mutableMapOf()
- InputValidator.validateVpa(state.vpa).also {
- if (!it.isValid) errors[FormField.VPA] = it.errorMessage!!
- }
- InputValidator.validateAmount(state.amount).also {
- if (!it.isValid) errors[FormField.AMOUNT] = it.errorMessage!!
+ when (state.targetType) {
+ PayTargetType.VPA -> InputValidator.validateVpa(state.vpa).also { if (!it.isValid) errors[FormField.VPA] = it.errorMessage!! }
+ PayTargetType.MOBILE_NUMBER -> InputValidator.validateMobileNumber(state.mobileNumber).also { if (!it.isValid) errors[FormField.MOBILE_NUMBER] = it.errorMessage!! }
}
+ InputValidator.validateAmount(state.amount).also { if (!it.isValid) errors[FormField.AMOUNT] = it.errorMessage!! }
+ if (mode != OperationMode.MANUAL) InputValidator.validatePin(state.pin).also { if (!it.isValid) errors[FormField.PIN] = it.errorMessage!! }
- // PIN is required only for automated modes; manual mode lets the
- // user enter the PIN themselves in the dialer.
- if (mode != OperationMode.MANUAL) {
- InputValidator.validatePin(state.pin).also {
- if (!it.isValid) errors[FormField.PIN] = it.errorMessage!!
- }
- }
-
- if (errors.isNotEmpty()) {
- _uiState.update { it.copy(errors = errors) }
- return
- }
+ if (errors.isNotEmpty()) { _uiState.update { it.copy(errors = errors) }; return }
_uiState.update { it.copy(errors = emptyMap()) }
- val cleanedVpa = state.vpa.trim()
- val cleanedAmount = state.amount.trim()
- val cleanedNote = state.note
-
+ val recipient = if (state.targetType == PayTargetType.VPA) state.vpa.trim() else state.mobileNumber.trim()
if (mode == OperationMode.MANUAL) {
- // Copy the recipient VPA so the user can paste it after the
- // dialer opens (most carriers' *99# UI accepts a paste into the
- // first prompt). Then fire the dialer with *99*1*3# prefilled.
- clipboardWriter(cleanedVpa)
- // In-app snackbar (visible only on the brief moment before the
- // dialer takes the foreground).
- _snackbar.value = "UPI ID copied — paste it on the *99# prompt"
- onDialerFallback("*99*1*3#")
- // System-level Toast so the same nudge actually surfaces on
- // top of the dialer, where the in-app snackbar can't reach.
- // We fire two toasts in sequence so the user sees one when
- // the dialer first appears, and one a couple seconds later
- // in case they missed the first.
- systemToast("UPI ID copied — please paste it from the clipboard on the *99# prompt")
- viewModelScope.launch {
- delay(1_800)
- systemToast("Paste the UPI ID from clipboard when the carrier asks for it")
- }
- // Reset transient session state — the user owns the dialer flow.
- _sessionState.value = SessionState.Idle
- return
+ clipboardWriter(recipient); onDialerFallback("*99*1*3#"); _sessionState.value = SessionState.Idle; return
}
-
- runPayment(cleanedVpa, cleanedAmount, cleanedNote, state.pin)
+ maybeRequestSimThenRun(recipient, state.amount.trim(), state.note, state.pin)
}
- /**
- * Validates inputs and starts the USSD payment session if valid.
- * Routes through ADVANCED or AUTO based on the user's preference.
- */
- private fun runPayment(vpa: String, amount: String, note: String, pin: String) {
- val mode = operationMode.value
-
- _uiState.update { it.copy(errors = emptyMap(), isSessionActive = true) }
- _sessionState.value = SessionState.Running(
- label = "Starting payment",
- stepIndex = 0,
- total = Actions.SendUpi.steps.size
- )
-
- // Show the appropriate overlay based on mode.
- when (mode) {
- OperationMode.AUTO -> {
- overlayController?.show(
- title = "Paying ₹$amount",
- subtitle = "to $vpa",
- stepLabel = "STARTING"
- )
- }
- OperationMode.ADVANCED -> {
- overlayController?.showMinimal(
- progress = 0,
- total = Actions.SendUpi.steps.size,
- label = "PAYING"
- )
- }
- OperationMode.MANUAL -> {
- // Shouldn't reach here; attemptPayment short-circuits MANUAL.
+ private fun maybeRequestSimThenRun(recipient: String, amount: String, note: String, pin: String) {
+ viewModelScope.launch {
+ val sims = carrierDetector.getAvailableSims()
+ if (sims.isEmpty()) { runPayment(recipient, amount, note, pin); return@launch }
+ if (operationMode.value == OperationMode.MANUAL) { runPayment(recipient, amount, note, pin); return@launch }
+
+ val targetSim = handleSimDetection(sims)
+ if (targetSim != null) {
+ ussdEngine.setPreferredSim(targetSim)
+ runPayment(recipient, amount, note, pin)
+ } else {
+ pendingPayment = PendingPayment(recipient, amount, note, pin)
+ _simOptions.value = sims
}
}
+ }
+
+ private fun runPayment(recipient: String, amount: String, note: String, pin: String) {
+ val action = if (_uiState.value.targetType == PayTargetType.VPA) Actions.SendUpi else Actions.SendToMobile
+ _uiState.update { it.copy(isSessionActive = true) }
+ _sessionState.value = SessionState.Running(label = "Starting payment", stepIndex = 0, total = action.steps.size)
+ overlayController?.show(title = "Paying ₹$amount", subtitle = recipient, stepLabel = "STARTING")
overlayController?.onCancel = { cancelSession() }
- val vars = mapOf(
- "vpa" to vpa,
- "amount" to amount,
- "note" to note.ifBlank { "Payment" },
- "pin" to pin
- )
+ val vars = if (_uiState.value.targetType == PayTargetType.VPA) mapOf("vpa" to recipient, "amount" to amount, "note" to note.ifBlank { "Payment" }, "pin" to pin)
+ else mapOf("mobileNumber" to recipient, "amount" to amount, "note" to note.ifBlank { "Payment" }, "pin" to pin)
- val run = actionRunner.runAction(Actions.SendUpi, vars, viewModelScope)
+ val run = actionRunner.runAction(action, vars, viewModelScope)
activeRun = run
-
sessionJob = viewModelScope.launch {
launch {
+ var lastCarrierPrompt: String? = null
run.events.collect { event ->
- // Once we've reached a terminal session state (Success
- // or Failed), don't let any straggling events from the
- // runner overwrite it. ActionRunner already guards
- // its own re-entrancy, but this is belt-and-braces for
- // any synthetic frame that races through.
- val current = _sessionState.value
- if (current is SessionState.Success || current is SessionState.Failed) {
- return@collect
- }
+ if (_sessionState.value !is SessionState.Running) return@collect
when (event) {
+ is ActionEvent.Frame -> lastCarrierPrompt = cleanCarrierText(event.frame.text)
is ActionEvent.Progress -> {
- _sessionState.value = SessionState.Running(
- label = event.label ?: "Processing",
- stepIndex = event.stepIndex,
- total = event.total
- )
- when (mode) {
- OperationMode.AUTO -> overlayController?.update(
- title = "Paying ₹$amount",
- subtitle = "to $vpa",
- stepLabel = (event.label ?: "Processing").uppercase()
- )
- OperationMode.ADVANCED -> overlayController?.updateMinimal(
- progress = event.stepIndex,
- total = event.total,
- label = "PAYING"
- )
- else -> Unit
+ _sessionState.value = SessionState.Running(label = event.label ?: "Processing", stepIndex = event.stepIndex, total = event.total, carrierText = lastCarrierPrompt)
+ if (action.steps.getOrNull(event.stepIndex)?.autoSubmit == false) {
+ overlayController?.onConfirm = { viewModelScope.launch { ussdEngine.submitFilledReply() } }
+ overlayController?.show(title = "Paying ₹$amount", subtitle = lastCarrierPrompt ?: recipient, stepLabel = "CONFIRM")
+ } else {
+ overlayController?.onConfirm = null
+ overlayController?.show(title = "Paying ₹$amount", subtitle = lastCarrierPrompt ?: recipient, stepLabel = (event.label ?: "Processing").uppercase())
}
}
- is ActionEvent.Done -> {
- _sessionState.value = SessionState.Success(resultText = event.resultText)
- overlayController?.hide()
- onSessionEnded()
- launch {
- historyRepo.recordTransaction(
- vpa = vpa,
- payeeName = _uiState.value.payeeName.ifBlank { null },
- amount = amount,
- note = note.ifBlank { null },
- carrierReply = event.resultText
- )
- }
- }
- is ActionEvent.Error -> {
- _sessionState.value = SessionState.Failed(
- message = event.message,
- resultText = event.resultText
- )
- overlayController?.showError(
- title = "Payment failed",
- message = event.message
- )
- onSessionEnded()
- }
- else -> { /* Frame/Reply — no UI state change needed */ }
+ is ActionEvent.Done -> { _sessionState.value = SessionState.Success(event.resultText); overlayController?.hide(); onSessionEnded(); launch { historyRepo.recordTransaction(recipient, _uiState.value.payeeName.ifBlank { null }, amount, note.ifBlank { null }, event.resultText) } }
+ is ActionEvent.Error -> { _sessionState.value = SessionState.Failed(event.message, event.resultText); overlayController?.showError("Payment failed", event.message); onSessionEnded() }
+ else -> Unit
}
}
}
-
val result = run.result.await()
- if (!result.success && _sessionState.value is SessionState.Running) {
- _sessionState.value = SessionState.Failed(
- message = result.resultText,
- resultText = result.resultText
- )
- overlayController?.showError(title = "Payment failed", message = result.resultText)
- onSessionEnded()
- }
+ if (!result.success && _sessionState.value is SessionState.Running) { _sessionState.value = SessionState.Failed(result.resultText, result.resultText); onSessionEnded() }
}
}
- /** Cancel the active session. */
- fun cancelSession() {
- viewModelScope.launch {
- activeRun?.cancel?.invoke()
- overlayController?.hide()
- _sessionState.value = SessionState.Idle
- onSessionEnded()
- }
+ private fun cleanCarrierText(text: String): String {
+ val lines = text.split("\n").filter { line -> line.isNotBlank() && !line.contains(Regex("^\\d+[.)]")) }
+ return if (lines.isEmpty()) text.trim() else lines.joinToString("\n").trim()
}
- /** Dismiss a terminal (success/failed) session card and return to the form. */
- fun dismissSession() {
- val wasSuccess = _sessionState.value is SessionState.Success
- _sessionState.value = SessionState.Idle
- if (wasSuccess) {
- // After success, clear the form so the user starts fresh.
- _uiState.update { PayUiState() }
- } else {
- // On failure, just clear the PIN so the user can re-enter it.
- _uiState.update { it.copy(pin = "") }
- }
- }
-
- fun clearFieldError(field: FormField) {
- _uiState.update { current ->
- if (current.errors.containsKey(field)) {
- current.copy(errors = current.errors - field)
- } else current
- }
- }
-
- fun dismissSnackbar() {
- _snackbar.value = null
- }
-
- fun onNavigateAway() {
- // Defensive: if the user backs out mid-form, drop the in-memory PIN.
- _uiState.update { it.copy(pin = "") }
- }
-
- fun onBackground() {
- _uiState.update { it.copy(pin = "") }
- }
-
- /**
- * Called whenever the session reaches a terminal state. Marks the
- * session as inactive and schedules a wipe of the PIN within 500ms so
- * it never lingers in memory longer than necessary.
- */
- private fun onSessionEnded() {
- _uiState.update { it.copy(isSessionActive = false) }
- activeRun = null
- viewModelScope.launch {
- delay(500)
- _uiState.update { it.copy(pin = "") }
- }
- }
+ fun cancelSession() { viewModelScope.launch { activeRun?.cancel?.invoke(); _sessionState.value = SessionState.Idle; onSessionEnded() } }
+ fun dismissSession() { if (_sessionState.value is SessionState.Success) _uiState.update { PayUiState() } else _uiState.update { it.copy(pin = "") }; _sessionState.value = SessionState.Idle }
+ fun dismissSnackbar() { _snackbar.value = null }
+ fun onNavigateAway() { _uiState.update { it.copy(pin = "") } }
+ fun onBackground() { _uiState.update { it.copy(pin = "") } }
+ override fun onCleared() { super.onCleared(); _uiState.update { it.copy(pin = "") } }
+ private fun onSessionEnded() { _uiState.update { it.copy(isSessionActive = false) }; activeRun = null; pendingPayment = null; _simOptions.value = null; ussdEngine.setPreferredSim(null); viewModelScope.launch { delay(500); _uiState.update { it.copy(pin = "") } } }
}
diff --git a/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt b/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt
index 4c22055..2b13d8c 100644
--- a/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt
+++ b/app/src/main/java/com/offpay/app/presentation/navigation/MainScaffold.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountBalance
import androidx.compose.material.icons.filled.Payments
@@ -71,9 +72,12 @@ fun OffPayApp() {
val scope = rememberCoroutineScope()
if (!firstLaunchDone) {
- OnboardingFlow(onComplete = {
- scope.launch { app.prefsRepo.setFirstLaunchComplete(true) }
- })
+ OnboardingFlow(
+ prefsRepo = app.prefsRepo,
+ onComplete = {
+ scope.launch { app.prefsRepo.setFirstLaunchComplete(true) }
+ }
+ )
} else {
MainScaffold(
app = app,
@@ -111,6 +115,7 @@ private fun MainScaffold(app: OffPayApplication, onReplayOnboarding: () -> Unit)
Modifier
.fillMaxSize()
.background(NeoPopColors.Black)
+ .navigationBarsPadding()
) {
Box(Modifier.weight(1f)) {
NavHost(
@@ -283,7 +288,9 @@ private fun rememberPayViewModel(app: OffPayApplication): PayViewModel {
actionRunner = app.actionRunner,
historyRepo = app.historyRepo,
prefsRepo = app.prefsRepo,
+ contactRepo = app.contactRepo,
carrierDetector = app.carrierDetector,
+ ussdEngine = app.ussdEngine,
overlayController = app.overlayController,
onDialerFallback = { code ->
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:$code"))
@@ -303,6 +310,8 @@ private fun rememberBalanceViewModel(app: OffPayApplication): BalanceViewModel {
BalanceViewModel(
actionRunner = app.actionRunner,
prefsRepo = app.prefsRepo,
+ carrierDetector = app.carrierDetector,
+ ussdEngine = app.ussdEngine,
overlayController = app.overlayController,
onDialerFallback = { code ->
val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:$code"))
diff --git a/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt b/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt
index 5d5367e..6d56eb7 100644
--- a/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt
+++ b/app/src/main/java/com/offpay/app/presentation/permissions/PermissionState.kt
@@ -31,6 +31,7 @@ data class PermissionStatus(
val callPhone: Boolean,
val readPhoneState: Boolean,
val camera: Boolean,
+ val contacts: Boolean,
val accessibility: Boolean,
val overlay: Boolean
) {
@@ -73,6 +74,7 @@ private fun currentStatus(context: Context): PermissionStatus {
callPhone = isGranted(context, Manifest.permission.CALL_PHONE),
readPhoneState = isGranted(context, Manifest.permission.READ_PHONE_STATE),
camera = isGranted(context, Manifest.permission.CAMERA),
+ contacts = isGranted(context, Manifest.permission.READ_CONTACTS),
accessibility = isAccessibilityServiceEnabled(context),
overlay = Settings.canDrawOverlays(context)
)
@@ -163,5 +165,7 @@ class PermissionLaunchers(
arrayOf(Manifest.permission.CALL_PHONE, Manifest.permission.READ_PHONE_STATE)
)
+ fun requestContacts() = single.launch(Manifest.permission.READ_CONTACTS)
+
fun requestCamera() = single.launch(Manifest.permission.CAMERA)
}
diff --git a/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt
index c008439..0502635 100644
--- a/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt
+++ b/app/src/main/java/com/offpay/app/presentation/screens/BalanceScreen.kt
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ErrorOutline
@@ -42,6 +43,7 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -84,9 +86,12 @@ fun BalanceScreen(
val ui by viewModel.uiState.collectAsState()
val session by viewModel.sessionState.collectAsState()
val mode by viewModel.operationMode.collectAsState()
+ val pinLength by viewModel.upiPinLength.collectAsState()
+ val defaultSimSlot by viewModel.defaultSimSlot.collectAsState()
val last by viewModel.lastResult.collectAsState()
val snackbar by viewModel.snackbar.collectAsState()
val txns by historyViewModel.transactions.collectAsState()
+ val simOptions by viewModel.simOptions.collectAsState()
val pinFocus = remember { FocusRequester() }
val keyboard = LocalSoftwareKeyboardController.current
@@ -110,14 +115,16 @@ fun BalanceScreen(
keyboard?.show()
}
}
- // Auto-fire when the user has tapped in 6 digits.
+ // Auto-fire disabled at user request to prevent accidental submissions.
+ /*
LaunchedEffect(ui.pin, mode) {
- if (ui.pin.length == 6 && mode != OperationMode.MANUAL && session is SessionState.Idle) {
+ if (ui.pin.length == pinLength && mode != OperationMode.MANUAL && session is SessionState.Idle) {
delay(250)
keyboard?.hide()
viewModel.attemptCheckBalance()
}
}
+ */
Box(
modifier
@@ -160,6 +167,7 @@ fun BalanceScreen(
mode = mode,
pin = ui.pin,
pinError = ui.pinError,
+ pinLength = pinLength,
onTapPin = {
runCatching { pinFocus.requestFocus() }
keyboard?.show()
@@ -219,6 +227,16 @@ fun BalanceScreen(
}
}
+ if (!simOptions.isNullOrEmpty()) {
+ SimPickerDialog(
+ sims = simOptions.orEmpty(),
+ title = ui.simPickerTitle,
+ onSelect = viewModel::onSimSelected,
+ onAskEveryTime = if (defaultSimSlot != -1) viewModel::onAskEveryTimeSelected else null,
+ onDismiss = viewModel::dismissSimSelection
+ )
+ }
+
// Hidden PIN field rendered as a SIBLING of the main content Column,
// not nested inside it. This keeps it in the layout tree (so it can
// hold focus and surface the IME) while staying out of any
@@ -244,7 +262,16 @@ fun BalanceScreen(
.focusRequester(pinFocus)
.size(1.dp)
.alpha(0f),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ keyboard?.hide()
+ viewModel.attemptCheckBalance()
+ }
+ ),
cursorBrush = SolidColor(NeoPopColors.Black),
singleLine = true,
textStyle = TextStyle(color = NeoPopColors.Black)
@@ -310,6 +337,7 @@ private fun IdleHero(
mode: OperationMode,
pin: String,
pinError: String?,
+ pinLength: Int,
onTapPin: () -> Unit
) {
Column(
@@ -359,7 +387,8 @@ private fun IdleHero(
BalancePinSection(
pin = pin,
onTap = onTapPin,
- error = pinError
+ error = pinError,
+ length = pinLength
)
}
}
@@ -398,7 +427,8 @@ private fun ManualHero() {
private fun BalancePinSection(
pin: String,
onTap: () -> Unit,
- error: String?
+ error: String?,
+ length: Int
) {
NeoPopAccentCard(
accent = NeoPopColors.Accent,
@@ -415,7 +445,7 @@ private fun BalancePinSection(
Spacer(Modifier.height(14.dp))
PinBoxes(
value = pin,
- length = 6,
+ length = length,
modifier = Modifier.fillMaxWidth()
)
if (error != null) {
@@ -452,10 +482,18 @@ private fun SessionRunning(state: SessionState.Running) {
NeoPopCard(modifier = Modifier.fillMaxWidth()) {
Column {
Text(
- text = state.label,
- style = NeoPopType.HeadlineLarge,
+ text = state.carrierText ?: state.label,
+ style = if (state.carrierText != null) NeoPopType.TitleLarge else NeoPopType.HeadlineLarge,
color = NeoPopColors.TextPrimary
)
+ if (state.carrierText != null && state.carrierText != state.label) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = state.label,
+ style = NeoPopType.LabelMedium,
+ color = NeoPopColors.Accent.copy(alpha = 0.7f)
+ )
+ }
Spacer(Modifier.height(8.dp))
Text(
text = "Step ${state.stepIndex + 1} of ${state.total}",
@@ -526,7 +564,7 @@ private fun FailedCard(message: String, resultText: String, onRetry: () -> Unit)
verticalArrangement = Arrangement.Center
) {
NeoPopAccentCard(accent = NeoPopColors.Danger, modifier = Modifier.fillMaxWidth()) {
- Column {
+ Column(Modifier.fillMaxWidth()) {
Box(
Modifier
.size(48.dp)
diff --git a/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt
index 10bf472..302e588 100644
--- a/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt
+++ b/app/src/main/java/com/offpay/app/presentation/screens/PayScreen.kt
@@ -16,8 +16,10 @@ import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -28,9 +30,11 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.QrCodeScanner
@@ -41,6 +45,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -59,7 +65,9 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.offpay.app.domain.FormField
@@ -84,6 +92,8 @@ import com.offpay.app.presentation.ui.theme.NeoPopType
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import androidx.compose.runtime.withFrameNanos
+import com.offpay.app.presentation.PayTargetType
+import com.offpay.app.presentation.ui.components.NeoPopToggle
@Composable
fun PayScreen(
@@ -97,13 +107,16 @@ fun PayScreen(
val ui by viewModel.uiState.collectAsState()
val session by viewModel.sessionState.collectAsState()
val mode by viewModel.operationMode.collectAsState()
+ val pinLength by viewModel.upiPinLength.collectAsState()
+ val defaultSimSlot by viewModel.defaultSimSlot.collectAsState()
val snackbar by viewModel.snackbar.collectAsState()
+ val simOptions by viewModel.simOptions.collectAsState()
// ── OffPay-wordmark easter egg ────────────────────────────────────
// Five quick taps on the wordmark trigger a falling-money rain across
// the screen. The counter resets if the user pauses for >1.4s.
- var wordmarkTaps by remember { mutableStateOf(0) }
- var lastWordmarkTap by remember { mutableStateOf(0L) }
+ var wordmarkTaps by remember { mutableIntStateOf(0) }
+ var lastWordmarkTap by remember { mutableLongStateOf(0L) }
var showMoneyRain by remember { mutableStateOf(false) }
LaunchedEffect(wordmarkTaps) {
if (wordmarkTaps in 1..4) {
@@ -133,13 +146,19 @@ fun PayScreen(
is SessionState.Idle -> PayForm(
ui = ui,
mode = mode,
+ pinLength = pinLength,
permissions = permissions,
+ onTargetTypeChanged = viewModel::onTargetTypeChanged,
onPay = { viewModel.attemptPayment() },
onScan = onNavigateScan,
onHistory = onNavigateHistory,
onVpa = { v -> viewModel.onFormFieldChanged(vpa = v) },
onAmount = { v -> viewModel.onFormFieldChanged(amount = v) },
onNote = { v -> viewModel.onFormFieldChanged(note = v) },
+ onMobileNumber = { v -> viewModel.onFormFieldChanged(mobileNumber = v) },
+ onContactSearch = viewModel::onContactSearch,
+ onContactSelected = viewModel::onContactSelected,
+ onSyncContacts = viewModel::syncContacts,
onPinChanged = viewModel::onPinChanged,
onWordmarkTap = onWordmarkTap
)
@@ -171,6 +190,16 @@ fun PayScreen(
onDone = { showMoneyRain = false }
)
}
+
+ if (!simOptions.isNullOrEmpty()) {
+ SimPickerDialog(
+ sims = simOptions.orEmpty(),
+ title = ui.simPickerTitle,
+ onSelect = viewModel::onSimSelected,
+ onAskEveryTime = if (defaultSimSlot != -1) viewModel::onAskEveryTimeSelected else null,
+ onDismiss = viewModel::dismissSimSelection
+ )
+ }
}
}
@@ -222,12 +251,18 @@ private fun SnackbarBanner(
private fun PayForm(
ui: PayUiState,
mode: OperationMode,
+ pinLength: Int,
permissions: PermissionStatus,
+ onTargetTypeChanged: (PayTargetType) -> Unit,
onPay: () -> Unit,
onScan: () -> Unit,
onHistory: () -> Unit,
onVpa: (String) -> Unit,
onAmount: (String) -> Unit,
+ onMobileNumber: (String) -> Unit,
+ onContactSearch: (String) -> Unit,
+ onContactSelected: (com.offpay.app.data.Contact) -> Unit,
+ onSyncContacts: () -> Unit,
onNote: (String) -> Unit,
onPinChanged: (String) -> Unit,
onWordmarkTap: () -> Unit
@@ -239,15 +274,6 @@ private fun PayForm(
val pinFocus = remember { FocusRequester() }
val scope = rememberCoroutineScope()
- // Defer the initial focus request until after the first layout pass
- // completes — requesting focus during composition (or before parent
- // placement) crashes the BringIntoView responder on Compose-foundation.
- LaunchedEffect(Unit) {
- withFrameNanos { /* one frame: layout placement is now committed */ }
- delay(50)
- runCatching { amountFocus.requestFocus() }
- }
-
// The PIN section only appears AFTER the user taps the primary Pay
// button (and the form's VPA + amount validate cleanly). Auto-popping
// it while the user was still typing the UPI ID was confusing — the
@@ -256,10 +282,14 @@ private fun PayForm(
// If the user edits the form back into an invalid state, hide the
// section again so the next "Pay" tap re-validates cleanly.
- val vpaLooksValid = ui.vpa.contains('@') && ui.vpa.substringAfter('@').isNotEmpty()
+ val recipientLooksValid = when (ui.targetType) {
+ PayTargetType.VPA -> ui.vpa.contains('@') && ui.vpa.substringAfter('@').isNotEmpty()
+ PayTargetType.MOBILE_NUMBER -> ui.mobileNumber.length == 10
+ }
val amountLooksValid = ui.amount.toDoubleOrNull()?.let { it > 0.0 } == true
- LaunchedEffect(vpaLooksValid, amountLooksValid, mode) {
- if (!vpaLooksValid || !amountLooksValid || mode == OperationMode.MANUAL) {
+
+ LaunchedEffect(recipientLooksValid, amountLooksValid, mode, ui.targetType) {
+ if (!recipientLooksValid || !amountLooksValid || mode == OperationMode.MANUAL) {
showPinSection = false
}
}
@@ -287,14 +317,23 @@ private fun PayForm(
}
}
- // Auto-fire when 6 digits entered and form is valid (debounced 250ms).
+ // Auto-fire disabled at user request. Users now manually tap the Pay button
+ // or press the keyboard's Done key.
+ /*
LaunchedEffect(ui.pin) {
- if (ui.pin.length == 6 && pinSectionVisible) {
+ if (ui.pin.length == pinLength && pinSectionVisible) {
delay(250)
keyboard?.hide()
onPay()
}
}
+ */
+
+ LaunchedEffect(permissions.contacts, ui.targetType) {
+ if (permissions.contacts && ui.targetType == PayTargetType.MOBILE_NUMBER && ui.contacts.isEmpty()) {
+ onSyncContacts()
+ }
+ }
// Click handler for the primary Pay button. In MANUAL mode it falls
// straight through to the ViewModel (clipboard + dialer fallback).
@@ -307,7 +346,7 @@ private fun PayForm(
onPay()
return@handler
}
- if (!vpaLooksValid || !amountLooksValid) {
+ if (!recipientLooksValid || !amountLooksValid) {
// Surface the field-level errors via the ViewModel's normal path.
onPay()
return@handler
@@ -357,21 +396,131 @@ private fun PayForm(
Spacer(Modifier.height(24.dp))
Text(
- text = "to",
+ text = "To",
style = NeoPopType.LabelMedium,
color = NeoPopColors.TextSecondary
)
Spacer(Modifier.height(10.dp))
- NeoPopTextField(
- value = ui.vpa,
- onValueChange = onVpa,
- label = "UPI ID",
- placeholder = "username@bank",
- error = ui.errors[FormField.VPA],
- keyboardType = KeyboardType.Email,
+
+ NeoPopToggle(
+ options = listOf(
+ PayTargetType.VPA to "UPI ID",
+ PayTargetType.MOBILE_NUMBER to "Mobile"
+ ),
+ selected = ui.targetType,
+ onSelect = onTargetTypeChanged,
modifier = Modifier.fillMaxWidth()
)
+
+
+ Spacer(Modifier.height(16.dp))
+ when (ui.targetType) {
+ PayTargetType.VPA -> {
+ NeoPopTextField(
+ value = ui.vpa,
+ onValueChange = onVpa,
+ label = "UPI ID",
+ placeholder = "username@bank",
+ error = ui.errors[FormField.VPA],
+ keyboardType = KeyboardType.Email,
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+
+ PayTargetType.MOBILE_NUMBER -> {
+ NeoPopTextField(
+ value = ui.mobileNumber,
+ onValueChange = { value ->
+ onMobileNumber(value.filter { it.isDigit() || it.isLetter() || it.isWhitespace() })
+ onContactSearch(value)
+ },
+ label = "Mobile Number / Name",
+ placeholder = "9876543210 or Name",
+ error = ui.errors[FormField.MOBILE_NUMBER],
+ keyboardType = KeyboardType.Text,
+ modifier = Modifier.fillMaxWidth(),
+ trailingIcon = {
+ Icon(
+ imageVector = Icons.Default.Contacts,
+ contentDescription = "Contacts",
+ tint = if (permissions.contacts) NeoPopColors.Accent else NeoPopColors.TextMuted,
+ modifier = Modifier
+ .size(24.dp)
+ .clickable {
+ if (permissions.contacts) {
+ onSyncContacts()
+ } else {
+ launchers.requestContacts()
+ }
+ }
+ )
+ }
+ )
+
+ // Contact search results
+ if (ui.contactSearchQuery.isNotEmpty() || (permissions.contacts && ui.mobileNumber.isEmpty())) {
+ val filteredContacts = remember(ui.contacts, ui.contactSearchQuery) {
+ if (ui.contactSearchQuery.isBlank()) {
+ ui.contacts.take(10)
+ } else {
+ ui.contacts.filter {
+ it.name.contains(ui.contactSearchQuery, ignoreCase = true) ||
+ it.phoneNumber.contains(ui.contactSearchQuery)
+ }.take(10)
+ }
+ }
+
+ if (filteredContacts.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ NeoPopCard(modifier = Modifier.fillMaxWidth()) {
+ Column {
+ filteredContacts.forEach { contact ->
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ onContactSelected(contact)
+ keyboard?.hide()
+ }
+ .padding(vertical = 12.dp, horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(NeoPopColors.SurfaceHigh),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = contact.name.take(1).uppercase(),
+ style = NeoPopType.LabelMedium,
+ color = NeoPopColors.Accent
+ )
+ }
+ Spacer(Modifier.width(12.dp))
+ Column {
+ Text(
+ text = contact.name,
+ style = NeoPopType.BodyMedium,
+ color = NeoPopColors.TextPrimary
+ )
+ Text(
+ text = contact.phoneNumber,
+ style = NeoPopType.LabelSmall,
+ color = NeoPopColors.TextSecondary
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
Spacer(Modifier.height(16.dp))
NeoPopTextField(
@@ -400,7 +549,8 @@ private fun PayForm(
scrollState.animateScrollTo(scrollState.maxValue)
}
},
- error = ui.errors[FormField.PIN]
+ error = ui.errors[FormField.PIN],
+ length = pinLength
)
}
}
@@ -460,7 +610,16 @@ private fun PayForm(
.focusRequester(pinFocus)
.size(1.dp)
.alpha(0f),
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.NumberPassword,
+ imeAction = ImeAction.Done
+ ),
+ keyboardActions = KeyboardActions(
+ onDone = {
+ keyboard?.hide()
+ onPayClicked()
+ }
+ ),
cursorBrush = SolidColor(NeoPopColors.Black),
singleLine = true,
textStyle = TextStyle(color = NeoPopColors.Black)
@@ -487,7 +646,8 @@ private fun PayForm(
private fun InlinePinSection(
value: String,
onTap: () -> Unit,
- error: String?
+ error: String?,
+ length: Int
) {
NeoPopAccentCard(
accent = NeoPopColors.Accent,
@@ -504,7 +664,7 @@ private fun InlinePinSection(
Spacer(Modifier.height(14.dp))
PinBoxes(
value = value,
- length = 6,
+ length = length,
modifier = Modifier.fillMaxWidth()
)
if (error != null) {
@@ -675,20 +835,25 @@ private fun AmountInput(
val borderColor = if (error != null) NeoPopColors.Danger else NeoPopColors.Accent
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
Text(
- text = "you pay",
+ text = "You pay",
style = NeoPopType.LabelMedium,
color = NeoPopColors.TextSecondary
)
- Spacer(Modifier.height(10.dp))
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Spacer(Modifier.height(16.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
Text(
text = "₹",
style = NeoPopType.MonoLarge.copy(
color = NeoPopColors.TextSecondary,
- fontSize = 40.sp
+ fontSize = 48.sp
)
)
- Spacer(Modifier.width(8.dp))
+
+ Spacer(Modifier.width(12.dp))
+
BasicTextField(
value = value,
onValueChange = { v ->
@@ -698,35 +863,38 @@ private fun AmountInput(
},
modifier = Modifier
.focusRequester(focusRequester)
- .padding(horizontal = 8.dp),
+ .width(IntrinsicSize.Min)
+ .defaultMinSize(minWidth = 40.dp),
textStyle = NeoPopType.MonoLarge.copy(
color = NeoPopColors.TextPrimary,
- fontSize = 56.sp,
+ fontSize = 64.sp,
fontWeight = FontWeight.Black
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
cursorBrush = SolidColor(NeoPopColors.Accent),
singleLine = true,
decorationBox = { inner ->
- if (value.isEmpty()) {
- Text(
- text = "0",
- style = NeoPopType.MonoLarge.copy(
- color = NeoPopColors.TextMuted,
- fontSize = 56.sp,
- fontWeight = FontWeight.Black
+ Box(contentAlignment = Alignment.CenterStart) {
+ if (value.isEmpty()) {
+ Text(
+ text = "0",
+ style = NeoPopType.MonoLarge.copy(
+ color = NeoPopColors.TextMuted,
+ fontSize = 64.sp,
+ fontWeight = FontWeight.Black
+ )
)
- )
+ }
+ inner()
}
- inner()
}
)
}
- Spacer(Modifier.height(8.dp))
+ Spacer(Modifier.height(12.dp))
Box(
Modifier
- .height(2.dp)
- .fillMaxWidth(0.6f)
+ .height(3.dp)
+ .fillMaxWidth(0.75f)
.background(borderColor)
)
if (error != null) {
@@ -762,10 +930,18 @@ private fun SessionRunningCard(state: SessionState.Running, onCancel: () -> Unit
NeoPopCard(modifier = Modifier.fillMaxWidth()) {
Column {
Text(
- text = state.label,
- style = NeoPopType.HeadlineLarge,
+ text = state.carrierText ?: state.label,
+ style = if (state.carrierText != null) NeoPopType.TitleLarge else NeoPopType.HeadlineLarge,
color = NeoPopColors.TextPrimary
)
+ if (state.carrierText != null && state.carrierText != state.label) {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ text = state.label,
+ style = NeoPopType.LabelMedium,
+ color = NeoPopColors.Accent.copy(alpha = 0.7f)
+ )
+ }
Spacer(Modifier.height(8.dp))
Text(
text = "Step ${state.stepIndex + 1} of ${state.total}",
diff --git a/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt b/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt
index 7d65a6c..82ba307 100644
--- a/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt
+++ b/app/src/main/java/com/offpay/app/presentation/screens/PaymentResultCards.kt
@@ -130,7 +130,7 @@ internal fun SessionSuccessCard(state: SessionState.Success, onDone: () -> Unit)
accent = NeoPopColors.Success,
modifier = Modifier.fillMaxWidth()
) {
- Column {
+ Column(Modifier.fillMaxWidth()) {
// Hero zone — confetti rendered behind, hero square in front.
Box(
Modifier
@@ -274,7 +274,7 @@ internal fun SessionFailedCard(
accent = NeoPopColors.Danger,
modifier = Modifier.fillMaxWidth()
) {
- Column {
+ Column(Modifier.fillMaxWidth()) {
Box(
Modifier
.fillMaxWidth()
diff --git a/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt
index 71c3c19..6023201 100644
--- a/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt
+++ b/app/src/main/java/com/offpay/app/presentation/screens/ScanScreen.kt
@@ -298,7 +298,10 @@ private fun CameraScannerContent(
icon = if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
contentDescription = "Torch",
diameter = 56.dp,
- onClick = { torchOn = !torchOn }
+ onClick = {
+ torchOn = !torchOn
+ qrManager.toggleTorch(torchOn)
+ }
)
GlassyCircleButton(
icon = Icons.Default.PhotoLibrary,
diff --git a/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt b/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt
index b8b2714..6e565b9 100644
--- a/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt
+++ b/app/src/main/java/com/offpay/app/presentation/screens/SettingsScreen.kt
@@ -76,6 +76,8 @@ import androidx.compose.ui.unit.dp
import com.offpay.app.R
import com.offpay.app.data.PreferencesRepository
import com.offpay.app.domain.OperationMode
+import com.offpay.app.domain.SimInfo
+import com.offpay.app.offPayApp
import com.offpay.app.presentation.HistoryViewModel
import com.offpay.app.presentation.permissions.PermissionStatus
import com.offpay.app.presentation.permissions.openAccessibilitySettings
@@ -104,9 +106,19 @@ fun SettingsScreen(
modifier: Modifier = Modifier
) {
val mode by prefsRepo.operationMode.collectAsState(initial = OperationMode.AUTO)
+ val pinLength by prefsRepo.upiPinLength.collectAsState(initial = 6)
+ val defaultSim by prefsRepo.defaultSimSlot.collectAsState(initial = -1)
+
val scope = rememberCoroutineScope()
val context = LocalContext.current
+ val app = context.offPayApp
val launchers = rememberPermissionLaunchers()
+
+ var detectedSims by remember { mutableStateOf>(emptyList()) }
+ LaunchedEffect(Unit) {
+ detectedSims = app.carrierDetector.getAvailableSims()
+ }
+
val allGranted = permissions.phoneBundle && permissions.camera &&
permissions.accessibility && permissions.overlay
var permissionsExpanded by remember { mutableStateOf(!allGranted) }
@@ -148,6 +160,70 @@ fun SettingsScreen(
Hairline()
Spacer(Modifier.height(24.dp))
+ // ── Preferences (PIN & SIM) ──
+ SectionHeader("Preferences")
+ Spacer(Modifier.height(16.dp))
+
+ Text(
+ text = "UPI PIN LENGTH",
+ style = NeoPopType.LabelSmall,
+ color = NeoPopColors.TextSecondary
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ PreferencePill(
+ label = "4 Digits",
+ selected = pinLength == 4,
+ onClick = { scope.launch { prefsRepo.setUpiPinLength(4) } },
+ modifier = Modifier.weight(1f)
+ )
+ PreferencePill(
+ label = "6 Digits",
+ selected = pinLength == 6,
+ onClick = { scope.launch { prefsRepo.setUpiPinLength(6) } },
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Spacer(Modifier.height(24.dp))
+
+ Text(
+ text = "DEFAULT SIM",
+ style = NeoPopType.LabelSmall,
+ color = NeoPopColors.TextSecondary
+ )
+ Spacer(Modifier.height(12.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ PreferencePill(
+ label = "Ask every time",
+ selected = defaultSim == -1,
+ onClick = {
+ scope.launch {
+ prefsRepo.setDefaultSimSlot(-1)
+ prefsRepo.setSelectedSimCarrier(null)
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ detectedSims.forEach { sim ->
+ PreferencePill(
+ label = "SIM ${sim.slotIndex + 1} (${sim.carrierName ?: "Unknown"})",
+ selected = defaultSim == sim.slotIndex,
+ onClick = {
+ scope.launch {
+ prefsRepo.setDefaultSimSlot(sim.slotIndex)
+ prefsRepo.setSelectedSimCarrier(sim.carrierName ?: "Unknown")
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+
+ Spacer(Modifier.height(28.dp))
+ Hairline()
+ Spacer(Modifier.height(24.dp))
+
// ── Transaction History — right below mode for quick access ──
ShortcutRow(
icon = Icons.Default.History,
@@ -540,6 +616,40 @@ private fun CreditRow(name: String, handle: String, onClick: () -> Unit) {
}
}
+@Composable
+private fun PreferencePill(
+ label: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val view = LocalView.current
+ Box(
+ modifier
+ .background(if (selected) NeoPopColors.Accent else NeoPopColors.SurfaceHigh)
+ .clickable {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
+ onClick()
+ }
+ .padding(vertical = 12.dp, horizontal = 16.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .size(8.dp)
+ .background(if (selected) NeoPopColors.Black else NeoPopColors.Border)
+ )
+ Spacer(Modifier.width(10.dp))
+ Text(
+ text = label,
+ style = NeoPopType.LabelMedium,
+ color = if (selected) NeoPopColors.Black else NeoPopColors.TextPrimary
+ )
+ }
+ }
+}
+
@Composable
private fun SectionHeader(label: String, danger: Boolean = false) {
Text(
diff --git a/app/src/main/java/com/offpay/app/presentation/screens/SimPickerDialog.kt b/app/src/main/java/com/offpay/app/presentation/screens/SimPickerDialog.kt
new file mode 100644
index 0000000..48e547f
--- /dev/null
+++ b/app/src/main/java/com/offpay/app/presentation/screens/SimPickerDialog.kt
@@ -0,0 +1,90 @@
+package com.offpay.app.presentation.screens
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.offpay.app.domain.SimInfo
+import com.offpay.app.presentation.ui.theme.NeoPopColors
+import com.offpay.app.presentation.ui.theme.NeoPopType
+
+@Composable
+fun SimPickerDialog(
+ sims: List,
+ title: String = "Choose SIM",
+ onSelect: (SimInfo) -> Unit,
+ onAskEveryTime: (() -> Unit)? = null,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text(title) },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ sims.forEach { sim ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onSelect(sim) }
+ .padding(vertical = 12.dp)
+ ) {
+ Column {
+ Text(
+ text = sim.carrierName?.ifBlank { "SIM ${sim.slotIndex + 1}" }
+ ?: "SIM ${sim.slotIndex + 1}",
+ fontWeight = FontWeight.Bold,
+ color = NeoPopColors.TextPrimary
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = "Slot ${sim.slotIndex + 1}",
+ color = NeoPopColors.TextSecondary,
+ style = NeoPopType.LabelSmall
+ )
+ }
+ }
+ }
+
+ if (onAskEveryTime != null) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onAskEveryTime() }
+ .padding(vertical = 12.dp)
+ ) {
+ Column {
+ Text(
+ text = "Ask every time",
+ fontWeight = FontWeight.Bold,
+ color = NeoPopColors.Accent
+ )
+ Spacer(Modifier.height(2.dp))
+ Text(
+ text = "Disable default SIM",
+ color = NeoPopColors.TextSecondary,
+ style = NeoPopType.LabelSmall
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {},
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ }
+ )
+}
diff --git a/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt b/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt
index a649a07..355b142 100644
--- a/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt
+++ b/app/src/main/java/com/offpay/app/presentation/screens/onboarding/OnboardingFlow.kt
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
@@ -53,6 +54,8 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -69,13 +72,12 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.offpay.app.R
+import com.offpay.app.data.PreferencesRepository
+import com.offpay.app.domain.SimInfo
+import com.offpay.app.offPayApp
import com.offpay.app.presentation.permissions.PermissionStatus
import com.offpay.app.presentation.permissions.openAccessibilitySettings
import com.offpay.app.presentation.permissions.openOverlaySettings
@@ -86,28 +88,24 @@ import com.offpay.app.presentation.ui.components.NeoPopPrimaryButton
import com.offpay.app.presentation.ui.components.NeoPopSecondaryButton
import com.offpay.app.presentation.ui.theme.NeoPopColors
import com.offpay.app.presentation.ui.theme.NeoPopType
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
// ─── Hooks for the user to drop in real assets later ──────────────────────────
-/**
- * BHIM walkthrough screenshots, mirrored from FaqScreen so the same images
- * surface here too.
- */
private val bhimStep1ImageRes: Int? = R.drawable.bhim_step1
private val bhimStep2ImageRes: Int? = R.drawable.bhim_step2
private val bhimStep3ImageRes: Int? = R.drawable.bhim_step3
-/**
- * Tutorial video URL. When non-null, the welcome page (page 1) renders a
- * small "Watch tutorial" link below the hero CTA.
- */
private val TUTORIAL_VIDEO_URL: String? = "https://youtube.com/playlist?list=PL6zhuU_l94t1y25MDt96Z-MltD3S6iPFj&si=GNlanTwR-IcfOBI"
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
@Composable
-fun OnboardingFlow(onComplete: () -> Unit) {
- val totalPages = 6
+fun OnboardingFlow(
+ prefsRepo: PreferencesRepository,
+ onComplete: () -> Unit
+) {
+ val totalPages = 7
val pagerState = rememberPagerState(pageCount = { totalPages })
val scope = rememberCoroutineScope()
@@ -119,10 +117,12 @@ fun OnboardingFlow(onComplete: () -> Unit) {
.fillMaxSize()
.background(NeoPopColors.Black)
.statusBarsPadding()
+ .navigationBarsPadding()
) {
HorizontalPager(
state = pagerState,
- modifier = Modifier.weight(1f)
+ modifier = Modifier.weight(1f),
+ userScrollEnabled = false
) { page ->
when (page) {
0 -> WelcomePage()
@@ -132,7 +132,11 @@ fun OnboardingFlow(onComplete: () -> Unit) {
scope.launch { pagerState.animateScrollToPage(4) }
})
4 -> PermissionsPage(permissions = permissions)
- 5 -> ReadyPage()
+ 5 -> AppSetupPage(
+ prefsRepo = prefsRepo,
+ isVisible = pagerState.currentPage == 5
+ )
+ 6 -> ReadyPage()
}
}
Spacer(Modifier.height(8.dp))
@@ -147,6 +151,7 @@ fun OnboardingFlow(onComplete: () -> Unit) {
2 -> "Next"
3 -> "Continue"
4 -> if (permissions.readyForOverlayPay) "Looks Good" else "Continue Anyway"
+ 5 -> "Save Preferences"
else -> "Let's Pay"
},
onClick = {
@@ -171,8 +176,6 @@ private fun PageIndicator(current: Int, total: Int) {
) {
repeat(total) { i ->
val isActive = i == current
- // 6dp dot inactive, 24dp lime pill active. Spring animation
- // smooths the transition between states.
val width by animateDpAsState(
targetValue = if (isActive) 24.dp else 6.dp,
animationSpec = spring(stiffness = androidx.compose.animation.core.Spring.StiffnessMedium),
@@ -193,8 +196,6 @@ private fun PageIndicator(current: Int, total: Int) {
}
}
-// ── Page 1: Welcome ──
-
@Composable
private fun WelcomePage() {
val context = LocalContext.current
@@ -240,8 +241,6 @@ private fun WelcomePage() {
style = NeoPopType.BodyLarge,
color = NeoPopColors.TextSecondary
)
- // Optional inline link to a tutorial video — only renders when the
- // top-of-file constant is set.
TUTORIAL_VIDEO_URL?.let { url ->
Spacer(Modifier.height(20.dp))
Row(
@@ -269,8 +268,6 @@ private fun WelcomePage() {
}
}
-// ── Page 2: What is *99# ──
-
@Composable
private fun Star99ExplainerPage() {
Column(
@@ -335,8 +332,6 @@ private fun CarrierPill(name: String, supported: Boolean, modifier: Modifier = M
}
}
-// ── Page 3: *99# Banking Setup (NEW) ──
-
@Composable
private fun Star99BankingSetupPage() {
val context = LocalContext.current
@@ -346,7 +341,6 @@ private fun Star99BankingSetupPage() {
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 24.dp)
) {
- // ── One-time badge ──
Box(
Modifier
.background(NeoPopColors.Accent.copy(alpha = 0.14f))
@@ -373,7 +367,6 @@ private fun Star99BankingSetupPage() {
)
Spacer(Modifier.height(20.dp))
- // ── Instructions card ──
NeoPopCard(modifier = Modifier.fillMaxWidth()) {
Column {
Text(
@@ -393,7 +386,6 @@ private fun Star99BankingSetupPage() {
Spacer(Modifier.height(20.dp))
- // ── Dial *99# button ──
NeoPopPrimaryButton(
text = "Dial *99#",
leadingIcon = Icons.Default.Phone,
@@ -407,7 +399,6 @@ private fun Star99BankingSetupPage() {
Spacer(Modifier.height(16.dp))
- // ── Video guide link ──
NeoPopSecondaryButton(
text = "Watch Official *99# Guide",
leadingIcon = Icons.Default.PlayArrow,
@@ -423,7 +414,6 @@ private fun Star99BankingSetupPage() {
Spacer(Modifier.height(20.dp))
- // ── Info note ──
Box(
Modifier
.fillMaxWidth()
@@ -448,8 +438,6 @@ private fun Star99BankingSetupPage() {
}
}
-// ── Page 4: BHIM setup (screenshots) ──
-
@Composable
private fun BhimSetupPage(onSkip: () -> Unit) {
Column(
@@ -479,20 +467,19 @@ private fun BhimSetupPage(onSkip: () -> Unit) {
NeoPopCard(modifier = Modifier.fillMaxWidth()) {
Column {
- NumberedStep(1, "Open BHIM app → tap your profile avatar (initials in top-left).")
+ NumberedStep(1, "Open BHIM app -> tap your profile avatar (initials in top-left).")
ImageSlot(res = bhimStep1ImageRes)
Spacer(Modifier.height(14.dp))
- NumberedStep(2, "Scroll down → tap Settings.")
+ NumberedStep(2, "Scroll down -> tap Settings.")
ImageSlot(res = bhimStep2ImageRes)
Spacer(Modifier.height(14.dp))
- NumberedStep(3, "Find \"USSD service (*99#)\" under Account settings → toggle it ON.")
+ NumberedStep(3, "Find \"USSD service (*99#)\" under Account settings -> toggle it ON.")
ImageSlot(res = bhimStep3ImageRes)
}
}
Spacer(Modifier.height(20.dp))
- // Official BHIM guide link
val bhimContext = LocalContext.current
Box(
Modifier
@@ -508,7 +495,7 @@ private fun BhimSetupPage(onSkip: () -> Unit) {
contentAlignment = Alignment.Center
) {
Text(
- text = "Official BHIM *99# Setup Guide →",
+ text = "Official BHIM *99# Setup Guide ->",
style = NeoPopType.LabelMedium,
color = NeoPopColors.Accent
)
@@ -531,70 +518,6 @@ private fun BhimSetupPage(onSkip: () -> Unit) {
}
}
-@Composable
-private fun NumberedStep(index: Int, body: String) {
- Row(verticalAlignment = Alignment.Top) {
- Box(
- Modifier
- .size(24.dp)
- .background(NeoPopColors.Accent),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = index.toString(),
- style = NeoPopType.LabelMedium,
- color = NeoPopColors.Black
- )
- }
- Spacer(Modifier.width(12.dp))
- Text(
- text = body,
- style = NeoPopType.BodyMedium,
- color = NeoPopColors.TextSecondary,
- modifier = Modifier.weight(1f)
- )
- }
-}
-
-@Composable
-private fun ImageSlot(res: Int?) {
- Spacer(Modifier.height(10.dp))
- if (res != null) {
- // Real screenshot: scale-to-fit, capped at 480dp tall so the portrait
- // BHIM screenshots don't dominate the screen on small devices.
- Image(
- painter = painterResource(id = res),
- contentDescription = null,
- contentScale = ContentScale.Fit,
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(max = 480.dp)
- )
- } else {
- Box(
- Modifier
- .fillMaxWidth()
- .aspectRatio(16f / 9f)
- .background(NeoPopColors.Surface)
- .drawBehind {
- drawRect(
- color = NeoPopColors.Accent.copy(alpha = 0.4f),
- style = Stroke(width = 1f)
- )
- },
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = "step image",
- style = NeoPopType.LabelMedium,
- color = NeoPopColors.TextMuted
- )
- }
- }
-}
-
-// ── Page 4: Permissions ──
-
private data class PermissionInfo(
val key: String,
val icon: ImageVector,
@@ -603,7 +526,6 @@ private data class PermissionInfo(
val why: String,
val granted: Boolean,
val onGrant: () -> Unit,
- /** When true, the GRANT button shows an inline FAQ-link hint below it. */
val showFaqHint: Boolean = false
)
@@ -640,8 +562,6 @@ private fun PermissionsPage(permissions: PermissionStatus) {
why = "Android's only public USSD API can't navigate multi-step menus. The accessibility service is what lets OffPay automatically type your amount, VPA and PIN into the carrier's dialog so you don't have to.",
granted = permissions.accessibility,
onGrant = { openAccessibilitySettings(context) },
- // Android 13+ may grey out the toggle. The FAQ has the workaround
- // — surface a tappable hint right next to the GRANT button.
showFaqHint = true
),
PermissionInfo(
@@ -789,7 +709,6 @@ private fun PermissionCard(info: PermissionInfo, onHelp: () -> Unit) {
Spacer(Modifier.width(10.dp))
StatusButton(granted = info.granted, onGrant = info.onGrant)
}
- // Inline "Can't enable?" expandable fix for restricted settings.
if (info.showFaqHint && !info.granted) {
Spacer(Modifier.height(10.dp))
RestrictedSettingsFix()
@@ -841,11 +760,6 @@ private fun StatusButton(granted: Boolean, onGrant: () -> Unit) {
}
}
-/**
- * Expandable inline fix for Android 13+ "Restricted Settings" that prevents
- * enabling accessibility for sideloaded apps. Shows a tappable "Can't enable?"
- * label that expands to reveal step-by-step instructions + guide link.
- */
@Composable
private fun RestrictedSettingsFix() {
val context = LocalContext.current
@@ -883,7 +797,6 @@ private fun RestrictedSettingsFix() {
.background(NeoPopColors.Surface)
.padding(14.dp)
) {
- // Explanation
Text(
text = "Why does this happen?",
style = NeoPopType.LabelSmall,
@@ -897,16 +810,15 @@ private fun RestrictedSettingsFix() {
)
Spacer(Modifier.height(12.dp))
- // Steps
Text(
text = "FIX:",
style = NeoPopType.LabelSmall,
color = NeoPopColors.Accent
)
Spacer(Modifier.height(8.dp))
- NumberedStep(1, "Go to your phone's Settings → Apps → OffPay")
+ NumberedStep(1, "Go to your phone's Settings -> Apps -> OffPay")
Spacer(Modifier.height(6.dp))
- NumberedStep(2, "Tap the ⋮ three dots in the top right corner")
+ NumberedStep(2, "Tap the : three dots in the top right corner")
Spacer(Modifier.height(6.dp))
NumberedStep(3, "Select \"Allow restricted settings\" and confirm with your PIN/fingerprint")
Spacer(Modifier.height(6.dp))
@@ -914,7 +826,6 @@ private fun RestrictedSettingsFix() {
Spacer(Modifier.height(14.dp))
- // Guide link
NeoPopSecondaryButton(
text = "View Guide with Screenshots",
onClick = {
@@ -931,7 +842,208 @@ private fun RestrictedSettingsFix() {
}
}
-// ── Page 6: Ready ──
+@Composable
+private fun NumberedStep(index: Int, body: String) {
+ Row(verticalAlignment = Alignment.Top) {
+ Box(
+ Modifier
+ .size(24.dp)
+ .background(NeoPopColors.Accent),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = index.toString(),
+ style = NeoPopType.LabelMedium,
+ color = NeoPopColors.Black
+ )
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(
+ text = body,
+ style = NeoPopType.BodyMedium,
+ color = NeoPopColors.TextSecondary,
+ modifier = Modifier.weight(1f)
+ )
+ }
+}
+
+@Composable
+private fun ImageSlot(res: Int?) {
+ Spacer(Modifier.height(10.dp))
+ if (res != null) {
+ Image(
+ painter = painterResource(id = res),
+ contentDescription = null,
+ contentScale = ContentScale.Fit,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 480.dp)
+ )
+ } else {
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .aspectRatio(16f / 9f)
+ .background(NeoPopColors.Surface)
+ .drawBehind {
+ drawRect(
+ color = NeoPopColors.Accent.copy(alpha = 0.4f),
+ style = Stroke(width = 1f)
+ )
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "step image",
+ style = NeoPopType.LabelMedium,
+ color = NeoPopColors.TextMuted
+ )
+ }
+ }
+}
+
+@Composable
+private fun AppSetupPage(
+ prefsRepo: PreferencesRepository,
+ isVisible: Boolean
+) {
+ val scope = rememberCoroutineScope()
+ val pinLength by prefsRepo.upiPinLength.collectAsState(initial = 6)
+ val defaultSim by prefsRepo.defaultSimSlot.collectAsState(initial = -1)
+
+ val context = LocalContext.current
+ val app = context.offPayApp
+ var detectedSims by remember { mutableStateOf>(emptyList()) }
+
+ LaunchedEffect(isVisible) {
+ if (isVisible) {
+ // Wait 500ms to ensure permissions are synchronized with the
+ // telephony system before scanning for SIMs.
+ delay(500)
+ detectedSims = app.carrierDetector.getAvailableSims()
+ }
+ }
+
+ Column(
+ Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 24.dp, vertical = 24.dp)
+ ) {
+ Text(
+ text = "APP SETTINGS",
+ style = NeoPopType.LabelMedium,
+ color = NeoPopColors.Accent
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = "Configure your preferences.",
+ style = NeoPopType.DisplayMedium,
+ color = NeoPopColors.TextPrimary
+ )
+ Spacer(Modifier.height(24.dp))
+
+ Text(
+ text = "UPI PIN LENGTH",
+ style = NeoPopType.LabelSmall,
+ color = NeoPopColors.TextSecondary
+ )
+ Spacer(Modifier.height(12.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ SelectionPill(
+ label = "4 Digits",
+ selected = pinLength == 4,
+ onClick = { scope.launch { prefsRepo.setUpiPinLength(4) } },
+ modifier = Modifier.weight(1f)
+ )
+ SelectionPill(
+ label = "6 Digits",
+ selected = pinLength == 6,
+ onClick = { scope.launch { prefsRepo.setUpiPinLength(6) } },
+ modifier = Modifier.weight(1f)
+ )
+ }
+
+ Spacer(Modifier.height(32.dp))
+
+ Text(
+ text = "DEFAULT SIM FOR TRANSACTIONS",
+ style = NeoPopType.LabelSmall,
+ color = NeoPopColors.TextSecondary
+ )
+ Spacer(Modifier.height(12.dp))
+
+ SelectionPill(
+ label = "Ask every time (Default)",
+ selected = defaultSim == -1,
+ onClick = {
+ scope.launch {
+ prefsRepo.setDefaultSimSlot(-1)
+ prefsRepo.setSelectedSimCarrier(null)
+ }
+ },
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ if (detectedSims.isNotEmpty()) {
+ Spacer(Modifier.height(8.dp))
+ detectedSims.forEach { sim ->
+ SelectionPill(
+ label = "SIM ${sim.slotIndex + 1} (${sim.carrierName ?: "Unknown"})",
+ selected = defaultSim == sim.slotIndex,
+ onClick = {
+ scope.launch {
+ prefsRepo.setDefaultSimSlot(sim.slotIndex)
+ prefsRepo.setSelectedSimCarrier(sim.carrierName ?: "Unknown")
+ }
+ },
+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp)
+ )
+ }
+ } else {
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = "No SIMs detected. Grant Phone permission in the previous step to see SIM options here.",
+ style = NeoPopType.BodySmall,
+ color = NeoPopColors.TextMuted
+ )
+ }
+ }
+}
+
+@Composable
+private fun SelectionPill(
+ label: String,
+ selected: Boolean,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val view = LocalView.current
+ Box(
+ modifier
+ .background(if (selected) NeoPopColors.Accent else NeoPopColors.SurfaceHigh)
+ .clickable {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
+ onClick()
+ }
+ .padding(vertical = 14.dp, horizontal = 16.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier
+ .size(10.dp)
+ .background(if (selected) NeoPopColors.Black else NeoPopColors.Border)
+ )
+ Spacer(Modifier.width(12.dp))
+ Text(
+ text = label,
+ style = NeoPopType.LabelMedium,
+ color = if (selected) NeoPopColors.Black else NeoPopColors.TextPrimary
+ )
+ }
+ }
+}
@Composable
private fun ReadyPage() {
diff --git a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt
index f35a53f..1ac3054 100644
--- a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt
+++ b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopCard.kt
@@ -3,6 +3,7 @@ package com.offpay.app.presentation.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
@@ -71,6 +72,7 @@ fun NeoPopCard(
) {
Box(
Modifier
+ .fillMaxWidth() // Ensure inner face matches outer container width
.padding(end = depth, bottom = depth)
.clip(RoundedCornerShape(cornerRadius))
.background(surfaceColor)
diff --git a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt
index c1b5f6e..10b1c01 100644
--- a/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt
+++ b/app/src/main/java/com/offpay/app/presentation/ui/components/NeoPopTextField.kt
@@ -49,6 +49,7 @@ fun NeoPopTextField(
modifier: Modifier = Modifier,
placeholder: String? = null,
leadingIcon: ImageVector? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
keyboardType: KeyboardType = KeyboardType.Text,
visualTransformation: VisualTransformation = VisualTransformation.None,
error: String? = null,
@@ -126,7 +127,7 @@ fun NeoPopTextField(
BasicTextField(
value = value,
onValueChange = onValueChange,
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.weight(1f),
textStyle = textStyle,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
visualTransformation = visualTransformation,
@@ -144,6 +145,10 @@ fun NeoPopTextField(
inner()
}
)
+ if (trailingIcon != null) {
+ Spacer(Modifier.size(10.dp))
+ trailingIcon()
+ }
}
}
}
diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt
index 75ee36f..775e74f 100644
--- a/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt
+++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerFailurePropertyTest.kt
@@ -23,7 +23,10 @@ class ActionRunnerFailurePropertyTest : FunSpec({
// Fake UssdEnginePort for creating ActionRunner instance
val fakeEngine = object : UssdEnginePort {
override suspend fun dial(code: String) {}
+ override fun setPreferredSim(simInfo: SimInfo?) {}
override suspend fun sendReply(reply: String): Boolean = true
+ override suspend fun fillReply(reply: String): Boolean = true
+ override suspend fun submitFilledReply(): Boolean = true
override suspend fun cancel() {}
override suspend fun dismissDialog(): Boolean = true
override fun getSessionId(): Int = 1
diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt
index ca1e3da..d1da4de 100644
--- a/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt
+++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerStepMatchPropertyTest.kt
@@ -24,7 +24,10 @@ class ActionRunnerStepMatchPropertyTest : FunSpec({
// tests call matchStep() and fillTemplate() directly.
val fakeEngine = object : UssdEnginePort {
override suspend fun dial(code: String) {}
+ override fun setPreferredSim(simInfo: SimInfo?) {}
override suspend fun sendReply(reply: String) = true
+ override suspend fun fillReply(reply: String) = true
+ override suspend fun submitFilledReply() = true
override suspend fun cancel() {}
override suspend fun dismissDialog() = true
override fun getSessionId() = 1
diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt
index 081f30d..c58d0a5 100644
--- a/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt
+++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerSuccessPropertyTest.kt
@@ -23,7 +23,10 @@ class ActionRunnerSuccessPropertyTest : FunSpec({
// Fake UssdEnginePort for creating ActionRunner instance
val fakeEngine = object : UssdEnginePort {
override suspend fun dial(code: String) {}
+ override fun setPreferredSim(simInfo: SimInfo?) {}
override suspend fun sendReply(reply: String): Boolean = true
+ override suspend fun fillReply(reply: String): Boolean = true
+ override suspend fun submitFilledReply(): Boolean = true
override suspend fun cancel() {}
override suspend fun dismissDialog(): Boolean = true
override fun getSessionId(): Int = 0
diff --git a/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt b/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt
index 1dd9c76..cdc1ba9 100644
--- a/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt
+++ b/app/src/test/java/com/offpay/app/domain/ActionRunnerTerminalPropertyTest.kt
@@ -27,7 +27,10 @@ class ActionRunnerTerminalPropertyTest : FunSpec({
// Fake UssdEnginePort for creating ActionRunner instance
val fakeEngine = object : UssdEnginePort {
override suspend fun dial(code: String) {}
+ override fun setPreferredSim(simInfo: SimInfo?) {}
override suspend fun sendReply(reply: String): Boolean = true
+ override suspend fun fillReply(reply: String): Boolean = true
+ override suspend fun submitFilledReply(): Boolean = true
override suspend fun cancel() {}
override suspend fun dismissDialog(): Boolean = true
override fun getSessionId(): Int = 1
diff --git a/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt b/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt
index 8c933d1..a73fb86 100644
--- a/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt
+++ b/app/src/test/java/com/offpay/app/platform/StaleFrameFilteringPropertyTest.kt
@@ -44,10 +44,13 @@ class StaleFrameFilteringPropertyTest : FunSpec({
var cancelCalled = false
override suspend fun dial(code: String) { dialCalled = true }
+ override fun setPreferredSim(simInfo: com.offpay.app.domain.SimInfo?) {}
override suspend fun sendReply(reply: String): Boolean {
repliesSent.add(reply)
return true
}
+ override suspend fun fillReply(reply: String): Boolean = true
+ override suspend fun submitFilledReply(): Boolean = true
override suspend fun cancel() { cancelCalled = true }
override suspend fun dismissDialog(): Boolean = true
override fun getSessionId(): Int = currentSessionId