Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b97d0c5a18
|
||
|
|
4ac328cf52
|
||
|
|
c2f473a6a3
|
||
|
|
9f40c56b49
|
||
|
|
0747fbdbfd
|
||
|
|
528e9eeef8
|
||
|
|
0e7f329a4b
|
||
|
|
2082e8fd0c
|
||
|
|
97a0cb218f
|
||
|
|
0158d6dcd8
|
||
|
|
320eaa2ffb
|
||
|
|
1886113ae7
|
||
|
|
41e7bc70e9
|
||
|
|
d786609bd1
|
||
|
|
2246e5929f
|
||
|
|
d0eee817ec
|
||
|
|
af414914c7
|
||
|
|
ec5b791a45
|
||
|
|
dd4aed0f94
|
||
|
|
fd4cdfecac
|
||
|
|
92f5af76e6
|
||
|
|
bc81255b31
|
||
|
|
acd11ef3eb
|
||
|
|
fc778f2a90
|
||
|
|
778bcc4d75
|
||
|
|
97c8033014
|
||
|
|
58d43f33d6
|
||
|
|
af791fc5ad
|
||
|
|
d8ef3a63c6
|
@@ -12,12 +12,16 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- Existing accounts with MIB, BML, or Fahipay
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank. See [how to get your TOTP seed](docs/thijooree/faq/totpseed/README.md)
|
||||
|
||||
## Download APK
|
||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||
|
||||
## FAQ
|
||||
|
||||
Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md).
|
||||
|
||||
## Privacy
|
||||
|
||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 29
|
||||
versionName = "1.0.28"
|
||||
versionCode = 33
|
||||
versionName = "1.0.32"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
@@ -45,20 +46,24 @@
|
||||
|
||||
<activity
|
||||
android:name=".LockActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.onboarding.OnboardingActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.login.LoginActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.HomeActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
|
||||
@@ -69,6 +74,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".nfc.BmlTapToPayActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.BasedBank" />
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
|
||||
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it
|
||||
* can go through the regular BML QR payment flow.
|
||||
*/
|
||||
class BmlMerchantTxnClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Returns the transaction's EMV QR payload (`vendorQrCode`).
|
||||
*
|
||||
* A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the
|
||||
* page itself sends need no auth and return the full transaction:
|
||||
* - on load, `activeBrowserId` (`<id>_<epoch millis>`);
|
||||
* - on picking "BML" as the payment method, `provider: bml_mpos`.
|
||||
*
|
||||
* A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects
|
||||
* one. Links already opened with BML chosen return it from the first.
|
||||
*/
|
||||
fun fetchQrPayload(transactionId: String): String {
|
||||
val browserId = JSONObject()
|
||||
.put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")
|
||||
patch(transactionId, browserId).vendorQrCode()?.let { return it }
|
||||
// Whether the provider PATCH returns the QR itself or it is generated a moment later has
|
||||
// not been observed, so re-read a few times before giving up. Re-reads use the load PATCH:
|
||||
// each provider PATCH counts as another payment attempt.
|
||||
var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML))
|
||||
repeat(3) {
|
||||
txn.vendorQrCode()?.let { return it }
|
||||
Thread.sleep(1000)
|
||||
txn = patch(transactionId, browserId)
|
||||
}
|
||||
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
|
||||
}
|
||||
|
||||
private fun patch(transactionId: String, body: JSONObject): JSONObject {
|
||||
val request = Request.Builder()
|
||||
.url("$API_BASE/transactions/$transactionId")
|
||||
.patch(body.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", PAGE_ORIGIN)
|
||||
.header("Referer", "$PAGE_ORIGIN/")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
if (!response.isSuccessful || !text.trimStart().startsWith("{"))
|
||||
throw Exception("Transaction lookup failed (HTTP ${response.code})")
|
||||
JSONObject(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup
|
||||
* already rejects a paid or expired QR with its own message. `isNull` first — `optString`
|
||||
* turns a JSON null into the string "null".
|
||||
*/
|
||||
private fun JSONObject.vendorQrCode(): String? =
|
||||
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
|
||||
|
||||
companion object {
|
||||
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
|
||||
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
|
||||
private const val PROVIDER_BML = "bml_mpos"
|
||||
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
|
||||
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
|
||||
|
||||
/** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */
|
||||
fun parseTransactionId(input: String): String? {
|
||||
val s = input.trim()
|
||||
val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1)
|
||||
return id?.lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
.build()
|
||||
|
||||
/** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */
|
||||
fun updateOtpSeed(otpSeed: String) {
|
||||
if (storedOtpSeed != null) storedOtpSeed = otpSeed
|
||||
}
|
||||
|
||||
// ─── Public entry point ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (securitySet) lock()
|
||||
}
|
||||
|
||||
// ── Payment guard ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The manifest has this activity handle theme, language, font-size and display-size changes
|
||||
// itself, because recreating it mid-payment tears down the screen waiting on the bank's
|
||||
// answer — the money can move with nothing left to say so. Those changes still need a
|
||||
// recreate to re-inflate with the new resources, so it runs straight away when nothing is in
|
||||
// flight and otherwise waits until the last payment finishes.
|
||||
|
||||
private var paymentsInFlight = 0
|
||||
private var recreatePending = false
|
||||
private var lastConfig: Configuration? = null
|
||||
|
||||
/** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */
|
||||
inner class PaymentGuard internal constructor() {
|
||||
private var ended = false
|
||||
fun end() {
|
||||
if (ended) return
|
||||
ended = true
|
||||
paymentsInFlight--
|
||||
if (paymentsInFlight == 0 && recreatePending) {
|
||||
recreatePending = false
|
||||
// Posted so a receipt screen committed in the same pass is saved with the state
|
||||
binding.root.post { recreate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */
|
||||
fun beginPayment(owner: LifecycleOwner): PaymentGuard {
|
||||
paymentsInFlight++
|
||||
val guard = PaymentGuard()
|
||||
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) = guard.end()
|
||||
})
|
||||
return guard
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
val previous = lastConfig
|
||||
lastConfig = Configuration(newConfig)
|
||||
// Size and orientation changes are handled in place; these need fresh resources.
|
||||
val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or
|
||||
android.content.pm.ActivityInfo.CONFIG_LOCALE or
|
||||
android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or
|
||||
android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or
|
||||
android.content.pm.ActivityInfo.CONFIG_DENSITY
|
||||
if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return
|
||||
if (paymentsInFlight > 0) recreatePending = true else recreate()
|
||||
}
|
||||
|
||||
fun lockApp() = lock()
|
||||
|
||||
fun notifyWheelLockTap() {
|
||||
@@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
setContentView(binding.root)
|
||||
lastConfig = Configuration(resources.configuration)
|
||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
|
||||
insets
|
||||
}
|
||||
|
||||
// The app bar only pads for the status bar. In landscape the navigation bar (and any
|
||||
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
|
||||
// draw underneath it, out of reach.
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
|
||||
val sides = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
v.updatePadding(left = sides.left, right = sides.right)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||
val frag = when (item.itemId) {
|
||||
@@ -528,6 +592,10 @@ fun applyNavLabelVisibility() {
|
||||
|
||||
private fun routeSharedQrText(text: String) {
|
||||
val store = CredentialStore(this)
|
||||
sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it))
|
||||
return
|
||||
}
|
||||
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||
if (bmlTarget != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||
|
||||
@@ -56,4 +56,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* for HTTP 5xx server errors from specific banks.
|
||||
*/
|
||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
||||
|
||||
/** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */
|
||||
var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft()
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -19,14 +30,22 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding
|
||||
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
|
||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.OtpauthParser
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
class OtpFragment : Fragment() {
|
||||
@@ -34,7 +53,35 @@ class OtpFragment : Fragment() {
|
||||
private var _binding: FragmentOtpBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private data class OtpEntry(val bank: String, val name: String?, val seed: String)
|
||||
private data class OtpEntry(
|
||||
val bank: String, val loginId: String, val account: String, val name: String?, val seed: String
|
||||
)
|
||||
|
||||
private val entries = mutableListOf<OtpEntry>()
|
||||
private var adapter: OtpAdapter? = null
|
||||
|
||||
/** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */
|
||||
private var scanTarget: android.widget.EditText? = null
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val target = scanTarget ?: return@registerForActivityResult
|
||||
val found = OtpauthParser.parse(raw)
|
||||
when {
|
||||
found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||
found.size == 1 -> target.setText(found[0].secret)
|
||||
else -> {
|
||||
val labels = found.map { e ->
|
||||
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||
}.toTypedArray()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Choose account")
|
||||
.setItems(labels) { _, i -> target.setText(found[i].secret) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||
@@ -56,6 +103,8 @@ class OtpFragment : Fragment() {
|
||||
)
|
||||
update(b, entry.seed)
|
||||
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
// Long-press opens the seed menu (export / update)
|
||||
b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true }
|
||||
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
|
||||
}
|
||||
@@ -91,6 +140,170 @@ class OtpFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSeedMenu(anchor: View, position: Int) {
|
||||
if (position == RecyclerView.NO_POSITION) return
|
||||
val popup = PopupMenu(anchor.context, anchor)
|
||||
popup.menu.add(0, 1, 0, "Export seed")
|
||||
popup.menu.add(0, 2, 1, "Update seed")
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false
|
||||
when (item.itemId) {
|
||||
1 -> showExportDialog(entry)
|
||||
2 -> showUpdateDialog(position)
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}"
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun showExportDialog(entry: OtpEntry) {
|
||||
val ctx = requireContext()
|
||||
val d = DialogOtpExportSeedBinding.inflate(layoutInflater)
|
||||
val uri = OtpauthParser.buildUri(entry.bank, entry.seed)
|
||||
d.tvSeed.text = entry.seed.chunked(4).joinToString(" ")
|
||||
renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) }
|
||||
d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") }
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(entryTitle(entry))
|
||||
.setView(d.root)
|
||||
.setPositiveButton("Done", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun renderQr(content: String, size: Int): Bitmap? = try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||
)
|
||||
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE }
|
||||
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
/** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */
|
||||
private fun copySensitive(context: Context, text: String, message: String) {
|
||||
val clip = ClipData.newPlainText("OTP seed", text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
clip.description.extras = PersistableBundle().apply {
|
||||
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||
}
|
||||
}
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun showUpdateDialog(position: Int) {
|
||||
val entry = entries.getOrNull(position) ?: return
|
||||
val ctx = requireContext()
|
||||
val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater)
|
||||
d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " +
|
||||
"can't be recovered. If you might still need it, export it first."
|
||||
|
||||
var newSeed: String? = null
|
||||
// Same behaviour as the sign-in screen's preview card
|
||||
val preview = d.cardOtp
|
||||
preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") }
|
||||
fun refreshPreview() {
|
||||
val seed = newSeed
|
||||
try {
|
||||
if (seed == null) throw IllegalArgumentException()
|
||||
preview.tvOtpCode.text = Totp.generate(seed)
|
||||
preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
preview.otpTimer.max = 30
|
||||
preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||
preview.root.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
preview.root.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle("Update seed · ${entry.bank}")
|
||||
.setView(d.root)
|
||||
.setPositiveButton("Replace", null)
|
||||
.setNegativeButton("Cancel", null)
|
||||
.create()
|
||||
|
||||
fun validate() {
|
||||
val raw = d.etNewSeed.text?.toString().orEmpty()
|
||||
newSeed = OtpauthParser.resolveSecret(raw)
|
||||
d.tilNewSeed.error = when {
|
||||
raw.isBlank() -> null
|
||||
newSeed == null -> "Not a valid TOTP seed"
|
||||
newSeed == entry.seed -> "This is already the current seed"
|
||||
else -> null
|
||||
}
|
||||
if (newSeed == entry.seed) newSeed = null
|
||||
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null
|
||||
refreshPreview()
|
||||
}
|
||||
|
||||
d.etNewSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) = validate()
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
d.btnScanNewSeed.setOnClickListener {
|
||||
scanTarget = d.etNewSeed
|
||||
qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
val ticker = viewLifecycleOwner.lifecycleScope.launch {
|
||||
while (isActive) { refreshPreview(); delay(1_000) }
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
ticker.cancel()
|
||||
if (scanTarget === d.etNewSeed) scanTarget = null
|
||||
}
|
||||
dialog.setOnShowListener {
|
||||
val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
replace.isEnabled = false
|
||||
replace.setOnClickListener {
|
||||
val seed = newSeed ?: return@setOnClickListener
|
||||
confirmReplace(entry) {
|
||||
saveSeed(position, seed)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Delete old seed?")
|
||||
.setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " +
|
||||
"recovered afterwards.")
|
||||
.setPositiveButton("Replace") { _, _ -> onConfirm() }
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun saveSeed(position: Int, seed: String) {
|
||||
val entry = entries.getOrNull(position) ?: return
|
||||
val store = CredentialStore(requireContext())
|
||||
when (entry.bank) {
|
||||
"MIB" -> {
|
||||
store.updateMibOtpSeed(entry.loginId, seed)
|
||||
// The live flow keeps the seed in memory for silent re-login
|
||||
(requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed)
|
||||
}
|
||||
"BML" -> store.updateBmlOtpSeed(entry.loginId, seed)
|
||||
}
|
||||
entries[position] = entry.copy(seed = seed)
|
||||
adapter?.notifyItemChanged(position)
|
||||
Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||
val code = text.toString().replace(" ", "")
|
||||
if (code.isEmpty() || code.contains('-')) return
|
||||
@@ -115,16 +328,19 @@ class OtpFragment : Fragment() {
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||
val name = store.loadMibFullName(loginId)
|
||||
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("mib", loginId) to
|
||||
OtpEntry("MIB", loginId, creds.username, name, creds.otpSeed))
|
||||
}
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("bml", loginId) to
|
||||
OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||
}
|
||||
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
|
||||
entries.clear()
|
||||
entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second })
|
||||
|
||||
val adapter = OtpAdapter(entries)
|
||||
val adapter = OtpAdapter(entries).also { this.adapter = it }
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||
@@ -147,8 +363,7 @@ class OtpFragment : Fragment() {
|
||||
mobile = profile.mobile,
|
||||
enrolled = profile.enrolled
|
||||
))
|
||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
@@ -168,8 +383,7 @@ class OtpFragment : Fragment() {
|
||||
idCard = info.idCard,
|
||||
birthdate = info.birthdate
|
||||
))
|
||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
@@ -192,6 +406,8 @@ class OtpFragment : Fragment() {
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
adapter = null
|
||||
scanTarget = null
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,15 +123,11 @@ class CardsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val screenW = resources.displayMetrics.widthPixels
|
||||
val peekPx = screenW / 8
|
||||
cardWidth = screenW - 2 * peekPx
|
||||
|
||||
stackAdapter = CardStackAdapter(cardWidth)
|
||||
stackAdapter = CardStackAdapter()
|
||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.rvCards.adapter = stackAdapter
|
||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
||||
binding.rvCards.clipToPadding = false
|
||||
applyCarouselWidth()
|
||||
|
||||
val snapHelper = PagerSnapHelper()
|
||||
snapHelper.attachToRecyclerView(binding.rvCards)
|
||||
@@ -637,8 +633,13 @@ class CardsFragment : Fragment() {
|
||||
|
||||
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
||||
|
||||
/** Held while tap mode is up: recreating the activity would clear the NFC payment token. */
|
||||
private var tapGuard: HomeActivity.PaymentGuard? = null
|
||||
|
||||
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||
isTapMode = enabled
|
||||
tapGuard?.end()
|
||||
tapGuard = if (enabled) (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) else null
|
||||
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||
}
|
||||
@@ -959,6 +960,25 @@ class CardsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes the carousel from the window width: each card leaves a 1/8 peek on either side. */
|
||||
private fun applyCarouselWidth() {
|
||||
val screenW = resources.displayMetrics.widthPixels
|
||||
val peekPx = screenW / 8
|
||||
cardWidth = screenW - 2 * peekPx
|
||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
||||
}
|
||||
|
||||
// HomeActivity handles size changes itself (rotation, split screen) rather than being
|
||||
// recreated, so the carousel has to re-measure for the new width on its own.
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
if (_binding == null) return
|
||||
applyCarouselWidth()
|
||||
stackAdapter.notifyDataSetChanged()
|
||||
binding.rvCards.scrollToPosition(currentCardPosition)
|
||||
binding.rvCards.post { if (_binding != null) applyCardScales() }
|
||||
}
|
||||
|
||||
private fun applyCardScales() {
|
||||
val rv = binding.rvCards
|
||||
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
||||
@@ -1055,7 +1075,7 @@ class CardsFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
||||
private inner class CardStackAdapter : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
||||
private var items: List<CardItem> = emptyList()
|
||||
|
||||
fun update(newItems: List<CardItem>) {
|
||||
@@ -1070,6 +1090,8 @@ class CardsFragment : Fragment() {
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(items[position])
|
||||
// Re-applied on every bind so a width change reaches recycled holders too
|
||||
holder.itemView.layoutParams.width = cardWidth
|
||||
// Pre-scale based on data position so initial render and off-screen cards are correct
|
||||
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
||||
val scale = 1f - 0.18f * fraction
|
||||
|
||||
@@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
val isDark = prefs.getString("theme", "system") == "dark"
|
||||
updatePitchBlackState(isDark)
|
||||
|
||||
// Receipts
|
||||
binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false)
|
||||
binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply()
|
||||
}
|
||||
|
||||
// Accent color
|
||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||
binding.accentToggle.check(when (savedPreset) {
|
||||
|
||||
@@ -39,6 +39,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
@@ -50,6 +51,7 @@ import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
|
||||
import sh.sar.basedbank.ui.home.transfer.TransferDraft
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
@@ -66,7 +68,16 @@ class TransferFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var selectedAccount: BankAccount? = null
|
||||
/**
|
||||
* The form lives on the activity's ViewModel (see [TransferDraft]) so a tab switch or a
|
||||
* theme/language recreation repaints it rather than starting over. The properties below are
|
||||
* the fragment's view of it.
|
||||
*/
|
||||
private val draft: TransferDraft get() = viewModel.transferDraft
|
||||
|
||||
private var selectedAccount: BankAccount?
|
||||
get() = draft.selectedAccount
|
||||
set(value) { draft.selectedAccount = value }
|
||||
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
|
||||
|
||||
/**
|
||||
@@ -77,21 +88,36 @@ class TransferFragment : Fragment() {
|
||||
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
|
||||
|
||||
// Resolved recipient info — set after successful lookup or prefill
|
||||
private var resolvedAccountNumber = ""
|
||||
private var resolvedRecipientName = ""
|
||||
private var resolvedBankName = ""
|
||||
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
||||
private var loadedToPhoto: Bitmap? = null
|
||||
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||
private var resolvedToOwnAccount: BankAccount? = null
|
||||
private var resolvedAccountNumber: String
|
||||
get() = draft.resolvedAccountNumber
|
||||
set(value) { draft.resolvedAccountNumber = value }
|
||||
private var resolvedRecipientName: String
|
||||
get() = draft.resolvedRecipientName
|
||||
set(value) { draft.resolvedRecipientName = value }
|
||||
private var resolvedBankName: String
|
||||
get() = draft.resolvedBankName
|
||||
set(value) { draft.resolvedBankName = value }
|
||||
private var loadedToPhoto: Bitmap?
|
||||
get() = draft.loadedToPhoto
|
||||
set(value) { draft.loadedToPhoto = value }
|
||||
private var resolvedDestCurrency: String
|
||||
get() = draft.resolvedDestCurrency
|
||||
set(value) { draft.resolvedDestCurrency = value }
|
||||
private var resolvedToOwnAccount: BankAccount?
|
||||
get() = draft.resolvedToOwnAccount
|
||||
set(value) { draft.resolvedToOwnAccount = value }
|
||||
private var savedToSubtitle: String
|
||||
get() = draft.toSubtitle
|
||||
set(value) { draft.toSubtitle = value }
|
||||
private var savedToColorHex: String
|
||||
get() = draft.toColorHex
|
||||
set(value) { draft.toColorHex = value }
|
||||
private var savedToImageHash: String?
|
||||
get() = draft.toImageHash
|
||||
set(value) { draft.toImageHash = value }
|
||||
|
||||
// Form state preserved across view destroy/create when the fragment instance is cached
|
||||
private var savedAmount = ""
|
||||
private var savedRemarks = ""
|
||||
private var savedToText = ""
|
||||
private var savedToSubtitle = ""
|
||||
private var savedToColorHex = "#607D8B"
|
||||
private var savedToImageHash: String? = null
|
||||
/** Set when this view applied the fragment's arguments, so the accounts observer may too. */
|
||||
private var argsAppliedThisView = false
|
||||
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
@@ -111,6 +137,7 @@ class TransferFragment : Fragment() {
|
||||
viewModel = viewModel,
|
||||
currentSource = { selectedAccount },
|
||||
selectSource = ::selectSourceAccount,
|
||||
clearSource = ::clearSourceAccount,
|
||||
onStateChanged = { updateTransferButton() },
|
||||
onTransferSuccess = { receipt, avatar ->
|
||||
ReceiptStore.save(requireContext(), receipt)
|
||||
@@ -177,13 +204,17 @@ class TransferFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
|
||||
|
||||
// BML Merchant Services payment link — resolve it to the QR its page would show
|
||||
BmlMerchantTxnClient.parseTransactionId(raw)?.let {
|
||||
binding.etTo.setText(it)
|
||||
lookupBmlMerchantTransaction(it)
|
||||
return
|
||||
}
|
||||
|
||||
// BML card/gateway/POS QR — hand off to dedicated payment screen
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
val fromCard = selectedAccount?.takeIf {
|
||||
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
|
||||
}
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
|
||||
openBmlQr(bmlTarget)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -247,6 +278,9 @@ class TransferFragment : Fragment() {
|
||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||
private const val ARG_AUTO_SCAN = "auto_scan"
|
||||
private const val ARG_BML_TXN_ID = "bml_txn_id"
|
||||
/** Set once the arguments have been applied, so later views restore the draft instead. */
|
||||
private const val ARG_APPLIED = "args_applied"
|
||||
|
||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||
@@ -259,6 +293,11 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens on a BML Merchant Services transaction ID, which is resolved to its QR on load. */
|
||||
fun newInstanceFromBmlTxn(transactionId: String) = TransferFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_BML_TXN_ID, transactionId) }
|
||||
}
|
||||
|
||||
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||
}
|
||||
@@ -304,6 +343,16 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
// A screen opened with its own arguments (scanned QR, picked contact…) starts a fresh
|
||||
// draft, once. Every later view — tab switch back, theme recreation — restores instead.
|
||||
// The flag lives in the arguments so it survives the fragment being recreated too.
|
||||
val args = arguments
|
||||
argsAppliedThisView = args != null && !args.getBoolean(ARG_APPLIED, false)
|
||||
if (argsAppliedThisView) {
|
||||
viewModel.transferDraft = TransferDraft()
|
||||
args!!.putBoolean(ARG_APPLIED, true)
|
||||
}
|
||||
|
||||
qrLauncher = requireActivity().activityResultRegistry.register(
|
||||
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
|
||||
) { onQrScanned(it) }
|
||||
@@ -367,6 +416,19 @@ class TransferFragment : Fragment() {
|
||||
|
||||
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
||||
|
||||
if (argsAppliedThisView) applyArguments()
|
||||
}
|
||||
|
||||
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||
super.onViewStateRestored(savedInstanceState)
|
||||
// Repaint here, not in onViewCreated: after a recreation the framework restores the
|
||||
// EditTexts' old text in between, and that setText on the To field fires its "user
|
||||
// edited the recipient" listener — which would hide a To card painted earlier.
|
||||
if (!argsAppliedThisView) restoreFromDraft()
|
||||
}
|
||||
|
||||
/** First view of a screen opened with arguments: prefill from them. */
|
||||
private fun applyArguments() {
|
||||
// Pre-select contact if navigated from contacts page or QR scan
|
||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||
prefillToDirectly(
|
||||
@@ -380,15 +442,34 @@ class TransferFragment : Fragment() {
|
||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||
|
||||
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
|
||||
arguments?.getString(ARG_BML_QR_URL)?.let { openBmlQr(it) }
|
||||
arguments?.getString(ARG_BML_TXN_ID)?.let {
|
||||
// Shown in the To field so a failed lookup leaves the ID there to retry or correct.
|
||||
binding.etTo.setText(it)
|
||||
lookupBmlMerchantTransaction(it)
|
||||
}
|
||||
|
||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||
launchQrScanner()
|
||||
}
|
||||
}
|
||||
|
||||
// Restore form state when view is recreated on the cached no-args instance
|
||||
if (arguments == null) {
|
||||
if (resolvedAccountNumber.isNotEmpty()) {
|
||||
/**
|
||||
* Repaints a recreated view from [draft] — nothing is looked up again. The From card is
|
||||
* repainted by the accounts observer; a BML QR lookup that never finished is retried there.
|
||||
*/
|
||||
private fun restoreFromDraft() {
|
||||
// Amount first: a dynamic merchant QR overwrites and locks it below
|
||||
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
|
||||
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
|
||||
val bmlQr = draft.bmlQrInfo
|
||||
val mfaisaQr = draft.mfaisaQrInfo
|
||||
val mfaisaRecipient = draft.mfaisaRecipient
|
||||
when {
|
||||
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
|
||||
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
|
||||
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
|
||||
resolvedAccountNumber.isNotEmpty() -> {
|
||||
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||
if (ownAccount != null) {
|
||||
showToCard(ownAccount)
|
||||
@@ -404,22 +485,40 @@ class TransferFragment : Fragment() {
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
||||
} else if (savedToText.isNotEmpty()) {
|
||||
binding.etTo.setText(savedToText, false)
|
||||
val photo = loadedToPhoto
|
||||
if (photo != null) {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(photo)
|
||||
} else {
|
||||
savedToImageHash?.let { loadToPhoto(it, isProfile = resolvedToOwnAccount != null) }
|
||||
}
|
||||
}
|
||||
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
||||
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
||||
updateTransferButton()
|
||||
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
|
||||
}
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
/** Restores the To-input row to its default state when a QR lookup fails. */
|
||||
internal fun resetToFieldVisibility() {
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
binding.btnScanQr.visibility = View.VISIBLE
|
||||
/**
|
||||
* Freezes the amount at a merchant's preset value. Unlike disabling the field this keeps it
|
||||
* at full colour — it's the figure being paid, so it should read clearly — and a lock icon
|
||||
* says why it can't be typed into.
|
||||
*/
|
||||
internal fun setAmountLocked(locked: Boolean) {
|
||||
binding.etAmount.apply {
|
||||
isFocusable = !locked
|
||||
isFocusableInTouchMode = !locked
|
||||
isCursorVisible = !locked
|
||||
isLongClickable = !locked
|
||||
if (locked) clearFocus()
|
||||
}
|
||||
binding.tilAmount.apply {
|
||||
if (locked) {
|
||||
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM
|
||||
endIconDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_lock)
|
||||
} else {
|
||||
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_NONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun startLookupLoading() {
|
||||
@@ -446,14 +545,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun setupFromDropdown() {
|
||||
binding.btnClearFromInfo.setOnClickListener {
|
||||
selectedAccount = null
|
||||
binding.tilAmount.prefixText = null
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.actvFrom.setText("", false)
|
||||
updateTransferButton()
|
||||
}
|
||||
binding.btnClearFromInfo.setOnClickListener { clearSourceAccount() }
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
||||
@@ -480,7 +572,7 @@ class TransferFragment : Fragment() {
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)?.takeIf { argsAppliedThisView }
|
||||
if (fromNumber != null && selectedAccount == null) {
|
||||
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
||||
if (match != null) {
|
||||
@@ -492,7 +584,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
||||
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
|
||||
if (selectedAccount == null && argsAppliedThisView && arguments?.getString(ARG_ACCOUNT) != null) {
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
||||
@@ -506,12 +598,9 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
||||
// onViewCreated runs. Retry the lookup once sessions are available.
|
||||
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
|
||||
if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl)
|
||||
}
|
||||
// onViewCreated runs; a lookup can also have been cut off by leaving the tab.
|
||||
// Retry it once sessions are available.
|
||||
draft.pendingBmlQrTarget?.let { bmlHandler().lookupQrMerchant(it) }
|
||||
|
||||
// Re-render the from card when the view is recreated on a cached instance
|
||||
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
||||
@@ -528,7 +617,7 @@ class TransferFragment : Fragment() {
|
||||
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
||||
if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) {
|
||||
if (resolvedAccountNumber.isNotBlank() && draft.mfaisaRecipient == null) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
@@ -541,8 +630,8 @@ class TransferFragment : Fragment() {
|
||||
binding.tilTo.hint = getString(R.string.transfer_to)
|
||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
// Drop any M-Faisa-resolved recipient when switching banks
|
||||
if (mfaisaHandler?.recipient != null) {
|
||||
mfaisaHandler?.clearState()
|
||||
if (draft.mfaisaRecipient != null) {
|
||||
mfaisaHandler().clearState()
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
@@ -713,6 +802,15 @@ class TransferFragment : Fragment() {
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
private fun clearSourceAccount() {
|
||||
selectedAccount = null
|
||||
binding.tilAmount.prefixText = null
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.actvFrom.setText("", false)
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
private fun updateAmountPrefix(account: BankAccount) {
|
||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||
}
|
||||
@@ -765,7 +863,41 @@ class TransferFragment : Fragment() {
|
||||
setupContactDropdown()
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source.
|
||||
* Done in place: reopening the screen for it rebuilt the whole form and made the To row
|
||||
* vanish and reappear.
|
||||
*/
|
||||
private fun openBmlQr(bmlTarget: String) {
|
||||
bmlHandler().lookupQrMerchant(bmlTarget)
|
||||
}
|
||||
|
||||
/**
|
||||
* A BML Merchant Services transaction ID (or its payment link) typed into the To field: fetch
|
||||
* the QR the payment page would show and pay it like a scanned one.
|
||||
*/
|
||||
private fun lookupBmlMerchantTransaction(transactionId: String) {
|
||||
startLookupLoading()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val target = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
|
||||
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
stopLookupLoading()
|
||||
if (target == null) {
|
||||
binding.tilTo.error = getString(R.string.transfer_bml_txn_lookup_failed)
|
||||
return@launch
|
||||
}
|
||||
openBmlQr(target)
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchTo() {
|
||||
BmlMerchantTxnClient.parseTransactionId(binding.etTo.text?.toString().orEmpty())?.let {
|
||||
lookupBmlMerchantTransaction(it)
|
||||
return
|
||||
}
|
||||
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
||||
if (selectedAccount?.bank == "MFAISA") {
|
||||
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
||||
@@ -1396,6 +1528,10 @@ class TransferFragment : Fragment() {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||
dialog.setCancelable(false)
|
||||
// From here until the outcome is dismissed the payment is in flight: a theme/language
|
||||
// change waits rather than recreating the screen out from under it.
|
||||
val guard = (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner)
|
||||
dialog.setOnDismissListener { guard?.end() }
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
val spinner = CircularProgressDrawable(ctx).apply {
|
||||
@@ -1556,6 +1692,7 @@ class TransferFragment : Fragment() {
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.tilAmount.prefixText = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
@@ -1657,15 +1794,14 @@ class TransferFragment : Fragment() {
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
// Persist form state so it can be restored when the view is recreated
|
||||
savedAmount = binding.etAmount.text?.toString() ?: ""
|
||||
savedRemarks = binding.etRemarks.text?.toString() ?: ""
|
||||
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
||||
// The bank handlers hold binding refs; drop them so the next view gets fresh ones.
|
||||
// Clearing also resets any in-progress OTP flow, which cannot sensibly resume.
|
||||
draft.amount = binding.etAmount.text?.toString() ?: ""
|
||||
draft.remarks = binding.etRemarks.text?.toString() ?: ""
|
||||
draft.toText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
||||
// The bank handlers hold binding refs; drop them so the next view gets fresh ones. What
|
||||
// they resolved lives in the draft; an in-progress BML OTP flow cannot sensibly resume.
|
||||
bmlHandler?.clearState()
|
||||
bmlHandler = null
|
||||
fahipayHandler = null
|
||||
mfaisaHandler?.clearState()
|
||||
mfaisaHandler = null
|
||||
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
|
||||
qrLauncher = null
|
||||
|
||||
@@ -168,6 +168,15 @@ class TransferReceiptFragment : Fragment() {
|
||||
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||
saveReceipt()
|
||||
}
|
||||
|
||||
val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("always_fullscreen_receipt", false)
|
||||
if (alwaysFullScreen) {
|
||||
// The normal page is skipped: keep it laid out (share/save capture its card) but hidden,
|
||||
// and leave the receipt entirely when the full-screen view is closed
|
||||
view.alpha = 0f
|
||||
view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data binding ──────────────────────────────────────────────────────────
|
||||
@@ -483,11 +492,11 @@ class TransferReceiptFragment : Fragment() {
|
||||
return bm
|
||||
}
|
||||
|
||||
private fun showFullScreenReceipt() {
|
||||
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
|
||||
val ctx = requireContext()
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
if (bank == "BML") { showBmlFullScreenReceipt(); return }
|
||||
if (bank == "MIB") { showMibFullScreenReceipt(); return }
|
||||
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
|
||||
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
|
||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||
|
||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||
@@ -529,6 +538,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||
if (closePageOnDismiss) closeReceiptPage()
|
||||
}
|
||||
dialog.show()
|
||||
dialog.window?.let { win ->
|
||||
@@ -545,7 +555,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
||||
* Follows the app theme (light/dark).
|
||||
*/
|
||||
private fun showBmlFullScreenReceipt() {
|
||||
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||
@@ -557,6 +567,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
))
|
||||
|
||||
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
|
||||
@@ -594,7 +605,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
* (visible) status bar, a floating close button top-right just below the status bar,
|
||||
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
|
||||
*/
|
||||
private fun showMibFullScreenReceipt() {
|
||||
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||
@@ -607,6 +618,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
))
|
||||
|
||||
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
|
||||
@@ -647,6 +659,12 @@ class TransferReceiptFragment : Fragment() {
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/** Pops this receipt off the back stack, returning to whatever screen opened it. */
|
||||
private fun closeReceiptPage() {
|
||||
if (!isAdded || parentFragmentManager.isStateSaved) return
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
|
||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||
for (tv in views) {
|
||||
tv.setOnLongClickListener {
|
||||
|
||||
@@ -59,6 +59,8 @@ class BmlTransferHandler(
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Asks the fragment to drop the selected source and show the empty From picker. */
|
||||
private val clearSource: () -> Unit,
|
||||
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||
private val onStateChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||
@@ -74,6 +76,18 @@ class BmlTransferHandler(
|
||||
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||
private var otpState = OtpState.NONE
|
||||
set(value) {
|
||||
// The whole OTP flow counts as a payment in flight: a theme change mid-way would
|
||||
// otherwise recreate the screen between initiate and confirm.
|
||||
if (field == OtpState.NONE && value != OtpState.NONE) {
|
||||
otpGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||
} else if (value == OtpState.NONE) {
|
||||
otpGuard?.end()
|
||||
otpGuard = null
|
||||
}
|
||||
field = value
|
||||
}
|
||||
private var otpGuard: HomeActivity.PaymentGuard? = null
|
||||
private var otpChannel: String? = null
|
||||
|
||||
private data class PendingTransfer(
|
||||
@@ -93,14 +107,15 @@ class BmlTransferHandler(
|
||||
)
|
||||
private var pendingTransfer: PendingTransfer? = null
|
||||
|
||||
// Merchant QR state lives in the draft so it outlives this handler (dropped with the view).
|
||||
private val draft get() = viewModel.transferDraft
|
||||
|
||||
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||
var qrInfo: BmlQrPayInfo? = null
|
||||
private set
|
||||
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
|
||||
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||
private var gatewayQr = false
|
||||
/** Prevents re-running the lookup after the user clears the merchant. */
|
||||
var qrLookupAttempted = false
|
||||
private set
|
||||
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
|
||||
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
|
||||
private var qrLookupInFlight = false
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
@@ -155,10 +170,11 @@ class BmlTransferHandler(
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
draft.pendingBmlQrTarget = null
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
gatewayQr = false
|
||||
binding.tilAmount.isEnabled = true
|
||||
draft.bmlQrInfo = null
|
||||
draft.bmlGatewayQr = false
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
@@ -173,18 +189,25 @@ class BmlTransferHandler(
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode.
|
||||
* Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment
|
||||
* retries once sessions load (cold start) or when the view comes back (tab switched away
|
||||
* mid-lookup).
|
||||
*/
|
||||
fun lookupQrMerchant(qrUrl: String) {
|
||||
qrLookupAttempted = true
|
||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
||||
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
|
||||
val draft = this.draft
|
||||
draft.pendingBmlQrTarget = qrUrl
|
||||
if (qrLookupInFlight) return
|
||||
val session = app.anyBmlSession() ?: return
|
||||
qrLookupInFlight = true
|
||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
// The To row stays on screen with a spinner while loading and is only swapped for the
|
||||
// merchant card once there is a merchant to show — hiding it up front left a gap that
|
||||
// made the form jump twice.
|
||||
fragment.startLookupLoading()
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
@@ -192,19 +215,32 @@ class BmlTransferHandler(
|
||||
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
qrLookupInFlight = false
|
||||
if (fragment.view == null) return@launch
|
||||
if (draft !== viewModel.transferDraft) return@launch
|
||||
fragment.stopLookupLoading()
|
||||
// Superseded: cleared meanwhile, or another QR was opened while this one ran
|
||||
val latest = draft.pendingBmlQrTarget
|
||||
if (latest != qrUrl) {
|
||||
latest?.let { lookupQrMerchant(it) }
|
||||
return@launch
|
||||
}
|
||||
draft.pendingBmlQrTarget = null
|
||||
val info = result.getOrNull()
|
||||
if (info == null) {
|
||||
// An expired or rejected QR is BML telling us something specific — show its own
|
||||
// wording and stay put with the To row restored, rather than bouncing the user out
|
||||
// of the screen they just scanned from.
|
||||
// wording and stay put with the To row as it was, rather than bouncing the user
|
||||
// out of the screen they just scanned from.
|
||||
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||
fragment.resetToFieldVisibility()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = info
|
||||
draft.bmlQrInfo = info
|
||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount
|
||||
// and need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||
draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||
if (info.amount == 0.0) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "bmlqr:$qrUrl",
|
||||
@@ -216,7 +252,13 @@ class BmlTransferHandler(
|
||||
))
|
||||
}
|
||||
|
||||
// Auto-select the user's default BML card if no card was pre-selected
|
||||
// Hide the To row before touching the source: repainting the From card re-syncs the
|
||||
// picker/scan buttons to the To row's visibility.
|
||||
hideToRow()
|
||||
|
||||
// Only a BML card can pay a merchant QR — drop any other source, then auto-select
|
||||
// the user's default card if no card was pre-selected
|
||||
if (currentSource()?.let { isCard(it) } == false) clearSource()
|
||||
if (currentSource() == null) {
|
||||
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
@@ -229,29 +271,44 @@ class BmlTransferHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||
binding.tvToAccountName.text = info.merchantName
|
||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill amount if dynamic QR
|
||||
if (info.amount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(info.amount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
// Remarks not applicable for merchant QR payments
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
|
||||
onStateChanged()
|
||||
showQrMerchant(info)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a
|
||||
* recreated view restores it — no network involved.
|
||||
*/
|
||||
fun showQrMerchant(info: BmlQrPayInfo) {
|
||||
hideToRow()
|
||||
// Clear button hidden (can't change recipient for QR)
|
||||
binding.tvToAccountName.text = info.merchantName
|
||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill amount if dynamic QR
|
||||
if (info.amount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(info.amount))
|
||||
fragment.setAmountLocked(true)
|
||||
}
|
||||
|
||||
// Remarks not applicable for merchant QR payments
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
|
||||
onStateChanged()
|
||||
}
|
||||
|
||||
private fun hideToRow() {
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
|
||||
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||
|
||||
@@ -54,8 +54,9 @@ class FahipayTransferHandler(
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||
var service: FahipayService? = null
|
||||
private set
|
||||
var service: FahipayService?
|
||||
get() = viewModel.transferDraft.fahipayService
|
||||
private set(value) { viewModel.transferDraft.fahipayService = value }
|
||||
|
||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||
|
||||
@@ -68,16 +68,29 @@ class MfaisaTransferHandler(
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
// Resolved state lives in the draft so it outlives this handler (dropped with the view).
|
||||
private val draft get() = viewModel.transferDraft
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient? = null
|
||||
private set
|
||||
var recipient: MfaisaTransferClient.Recipient?
|
||||
get() = draft.mfaisaRecipient
|
||||
private set(value) { draft.mfaisaRecipient = value }
|
||||
|
||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||
private set
|
||||
var qrInfo: MfaisaQrPayClient.QrMerchant?
|
||||
get() = draft.mfaisaQrInfo
|
||||
private set(value) { draft.mfaisaQrInfo = value }
|
||||
|
||||
private var lookupInFlight = false
|
||||
|
||||
/** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */
|
||||
private var transferGuard: HomeActivity.PaymentGuard? = null
|
||||
|
||||
private fun endTransferFlow() {
|
||||
transferGuard?.end()
|
||||
transferGuard = null
|
||||
}
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||
@@ -154,6 +167,8 @@ class MfaisaTransferHandler(
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
endTransferFlow()
|
||||
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val refId = try {
|
||||
@@ -161,6 +176,7 @@ class MfaisaTransferHandler(
|
||||
} catch (e: Exception) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
endTransferFlow()
|
||||
showError(e)
|
||||
return@launch
|
||||
}
|
||||
@@ -183,7 +199,7 @@ class MfaisaTransferHandler(
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
@@ -209,10 +225,8 @@ class MfaisaTransferHandler(
|
||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
// The To row stays up with a spinner until there is a merchant to swap in
|
||||
fragment.startLookupLoading()
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
@@ -227,9 +241,10 @@ class MfaisaTransferHandler(
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
if (fragment.view == null) return@launch
|
||||
fragment.stopLookupLoading()
|
||||
if (merchant == null) {
|
||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||
fragment.resetToFieldVisibility()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = merchant
|
||||
@@ -248,26 +263,37 @@ class MfaisaTransferHandler(
|
||||
))
|
||||
}
|
||||
|
||||
// Show merchant in the "To" card — clear button is the only way to back out
|
||||
binding.tvToAccountName.text = merchant.merchantName
|
||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill + lock amount if the QR is dynamic
|
||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
onRecipientChanged()
|
||||
showQrMerchant(merchant)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a
|
||||
* recreated view restores it — no network involved.
|
||||
*/
|
||||
fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) {
|
||||
// Clear button is the only way to back out
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.tvToAccountName.text = merchant.merchantName
|
||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill + lock amount if the QR is dynamic
|
||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||
fragment.setAmountLocked(true)
|
||||
}
|
||||
|
||||
onRecipientChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||
@@ -397,7 +423,8 @@ class MfaisaTransferHandler(
|
||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||
|
||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||
/** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */
|
||||
fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) {
|
||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||
// card view, so we just populate its text fields and toggle visibility.
|
||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||
@@ -413,7 +440,7 @@ class MfaisaTransferHandler(
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
if (saveRecent) RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = r.msisdn,
|
||||
displayName = r.name.ifBlank { r.msisdn },
|
||||
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||
@@ -470,7 +497,7 @@ class MfaisaTransferHandler(
|
||||
refId: String,
|
||||
errorMsg: String?
|
||||
) {
|
||||
val view = fragment.view ?: return
|
||||
val view = fragment.view ?: run { endTransferFlow(); return }
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val colorMuted = MaterialColors.getColor(
|
||||
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||
@@ -559,6 +586,7 @@ class MfaisaTransferHandler(
|
||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
endTransferFlow()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
@@ -583,6 +611,7 @@ class MfaisaTransferHandler(
|
||||
try {
|
||||
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
endTransferFlow()
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountValue,
|
||||
@@ -607,6 +636,7 @@ class MfaisaTransferHandler(
|
||||
} catch (e: Exception) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
endTransferFlow()
|
||||
showError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
/**
|
||||
* Everything the Transfer screen has filled in or resolved so far: source, recipient, form
|
||||
* text and any loaded merchant QR.
|
||||
*
|
||||
* Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives
|
||||
* both the view (switching tabs) and the fragment instance (a theme or language change
|
||||
* recreates the activity) — the screen is repainted from here instead of re-running lookups.
|
||||
* A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh
|
||||
* draft.
|
||||
*/
|
||||
class TransferDraft {
|
||||
var selectedAccount: BankAccount? = null
|
||||
|
||||
// Resolved recipient — set after a successful lookup or prefill
|
||||
var resolvedAccountNumber = ""
|
||||
var resolvedRecipientName = ""
|
||||
var resolvedBankName = ""
|
||||
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
||||
var loadedToPhoto: Bitmap? = null
|
||||
var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||
var resolvedToOwnAccount: BankAccount? = null
|
||||
var toSubtitle = ""
|
||||
var toColorHex = "#607D8B"
|
||||
var toImageHash: String? = null
|
||||
|
||||
// Form text, captured when the view goes away
|
||||
var amount = ""
|
||||
var remarks = ""
|
||||
var toText = ""
|
||||
|
||||
// BML merchant QR
|
||||
var bmlQrInfo: BmlQrPayInfo? = null
|
||||
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
|
||||
var bmlGatewayQr = false
|
||||
/** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */
|
||||
var pendingBmlQrTarget: String? = null
|
||||
|
||||
// M-Faisa
|
||||
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
|
||||
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||
|
||||
// Fahipay
|
||||
var fahipayService: FahipayService? = null
|
||||
}
|
||||
@@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
binding.cardOtp.setOnClickListener {
|
||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||
binding.cardOtp.root.setOnClickListener {
|
||||
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
|
||||
if (code.isNotEmpty()) {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
@@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() {
|
||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||
val seed = resolveOtpSeed(otpSeedRaw)
|
||||
if (seed.isEmpty()) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
val password = binding.etPassword.text.toString()
|
||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -211,13 +211,13 @@ class CredentialsFragment : Fragment() {
|
||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||
val remaining = 30 - secondsInPeriod
|
||||
|
||||
binding.tvOtpCode.text = otp
|
||||
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
binding.otpTimer.max = 30
|
||||
binding.otpTimer.progress = remaining
|
||||
binding.cardOtp.visibility = View.VISIBLE
|
||||
binding.cardOtp.tvOtpCode.text = otp
|
||||
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
binding.cardOtp.otpTimer.max = 30
|
||||
binding.cardOtp.otpTimer.progress = remaining
|
||||
binding.cardOtp.root.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,11 @@ class CredentialStore(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||
fun updateMibOtpSeed(loginId: String, otpSeed: String) {
|
||||
prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||
}
|
||||
|
||||
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||
@@ -230,6 +235,11 @@ class CredentialStore(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||
fun updateBmlOtpSeed(loginId: String, otpSeed: String) {
|
||||
prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||
}
|
||||
|
||||
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||
|
||||
@@ -13,6 +13,27 @@ object OtpauthParser {
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw
|
||||
* secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed.
|
||||
*/
|
||||
fun resolveSecret(input: String): String? {
|
||||
val raw = input.trim()
|
||||
val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw
|
||||
val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase()
|
||||
if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null
|
||||
// Too short to be a real seed, e.g. a pasted 6-digit OTP code
|
||||
if (clean.length < 8) return null
|
||||
return clean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shortest otpauth://totp link authenticator apps accept: just a label and the
|
||||
* secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out.
|
||||
*/
|
||||
fun buildUri(label: String, secret: String): String =
|
||||
"otpauth://totp/" + Uri.encode(label) + "?secret=$secret"
|
||||
|
||||
private fun parseStandard(raw: String): OtpEntry? {
|
||||
val uri = Uri.parse(raw)
|
||||
val secret = uri.getQueryParameter("secret") ?: return null
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="Scan this with your authenticator app, or copy the seed. Please keep it private, since anyone who has it can generate your OTP codes."
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<!-- Always black-on-white so scanners read it in dark mode too -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@android:color/white"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivSeedQr"
|
||||
android:layout_width="220dp"
|
||||
android:layout_height="220dp"
|
||||
android:layout_margin="12dp"
|
||||
android:contentDescription="OTP seed QR code" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:fontFamily="monospace"
|
||||
android:gravity="center"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textIsSelectable="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCopySeed"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="Copy seed"
|
||||
app:icon="@drawable/ic_copy" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="8dp">
|
||||
|
||||
<!-- Warning: the old seed is overwritten -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||
app:cardCornerRadius="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_info"
|
||||
app:tint="?attr/colorOnErrorContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSeedWarning"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnErrorContainer" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilNewSeed"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="New OTP seed"
|
||||
app:helperText="Base32 secret or otpauth:// link">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etNewSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textNoSuggestions|textVisiblePassword"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanNewSeed"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/scan_otp_qr"
|
||||
android:tooltipText="@string/scan_otp_qr"
|
||||
app:icon="@drawable/ic_qr_scan" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Live preview of the new seed's code, same card as the sign-in screen -->
|
||||
<include
|
||||
android:id="@+id/cardOtp"
|
||||
layout="@layout/view_otp_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -130,89 +130,13 @@
|
||||
android:maxLength="6" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
<include
|
||||
android:id="@+id/cardOtp"
|
||||
layout="@layout/view_otp_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="invisible"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current OTP"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.15"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="end"
|
||||
android:alpha="0.7">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Next"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNextOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpTimer"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
app:indicatorSize="32dp"
|
||||
app:trackThickness="3dp"
|
||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||
app:trackColor="?attr/colorSecondaryContainer"
|
||||
android:indeterminate="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvError"
|
||||
|
||||
@@ -304,6 +304,34 @@
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_receipts"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_always_fullscreen_receipt"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchFullscreenReceipt"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Live TOTP preview card, shared by the sign-in screen and the OTP screen's "Update seed" dialog -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="invisible"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current OTP"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.15"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="end"
|
||||
android:alpha="0.7">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Next"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNextOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpTimer"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
app:indicatorSize="32dp"
|
||||
app:trackThickness="3dp"
|
||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||
app:trackColor="?attr/colorSecondaryContainer"
|
||||
android:indeterminate="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -157,6 +157,8 @@
|
||||
<string name="theme_dark">Dark</string>
|
||||
<string name="settings_pitch_black">Pitch Black</string>
|
||||
<string name="settings_accent_color">Accent Color</string>
|
||||
<string name="settings_receipts">Receipts</string>
|
||||
<string name="settings_always_fullscreen_receipt">Always show full screen receipt</string>
|
||||
<string name="accent_blue">Blue</string>
|
||||
<string name="accent_orange">Red</string>
|
||||
<string name="accent_green">Green</string>
|
||||
@@ -294,6 +296,7 @@
|
||||
<!-- BML QR Pay -->
|
||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
|
||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" baseProfile="basic" id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
||||
xml:space="preserve">
|
||||
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
|
||||
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
|
||||
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"/>
|
||||
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
|
||||
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
|
||||
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
|
||||
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"/>
|
||||
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
|
||||
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
|
||||
L256,193.98999z"/>
|
||||
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
|
||||
C72,238.87917,85.87916,225,102.99997,225H248z"/>
|
||||
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 "/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -21,6 +21,29 @@ Each card shows:
|
||||
|
||||
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
|
||||
|
||||
---
|
||||
|
||||
## Seed Actions
|
||||
|
||||
Long-pressing a card opens a menu with:
|
||||
|
||||
### Export seed
|
||||
|
||||
A dialog titled `{bank} · {name}` showing:
|
||||
- A QR code of a minimal `otpauth://totp/{BANK}?secret=…` link (e.g. `otpauth://totp/BML?secret=…`), always drawn black-on-white so it scans in dark mode. No username or issuer is included, and algorithm, digits and period are left out because SHA1, 6 and 30s are the spec defaults. Export is single-account only; `otpauth-migration://` is supported for import but never produced.
|
||||
- The Base32 seed in groups of 4 (selectable text)
|
||||
- A **Copy seed** button. The copy is flagged `EXTRA_IS_SENSITIVE`, so Android 13+ hides the value in the clipboard preview.
|
||||
|
||||
### Update seed
|
||||
|
||||
Replaces the stored seed for that login, e.g. after re-enrolling the authenticator with the bank.
|
||||
- A red warning banner explains that the old seed is deleted permanently.
|
||||
- The new seed can be typed or pasted (raw Base32 or an `otpauth://` link) or scanned with the QR button. Scans that contain several accounts (`otpauth-migration://`) ask which one to use.
|
||||
- Once the input is a valid seed, a live preview shows its current and next code with a countdown so the user can check it against the bank before saving. The preview is the same card as the sign-in screen (`view_otp_preview.xml`, shared by both), and tapping it copies the code. Input that isn't valid Base32, is shorter than 8 characters (such as a pasted 6-digit code), or matches the current seed disables **Replace**.
|
||||
- **Replace** asks for confirmation ("Delete old seed?"). Confirming calls `CredentialStore.updateMibOtpSeed()` / `updateBmlOtpSeed()`, which overwrite only the encrypted seed. For MIB it also calls `MibLoginFlow.updateOtpSeed()` so silent re-login uses the new seed.
|
||||
|
||||
The username, password and sessions are not touched. Other places that need an OTP (transfers, QR pay, pay with card) read the seed from `CredentialStore` each time, so they pick up the new seed immediately.
|
||||
|
||||
### Algorithm
|
||||
|
||||
Standard RFC 6238 TOTP:
|
||||
@@ -58,7 +81,7 @@ The OTP screen is informational — the user copies the displayed code manually
|
||||
|
||||
## Security
|
||||
|
||||
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports.
|
||||
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. They leave the app only when the user chooses **Export seed**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# FAQ
|
||||
|
||||
## [What is and how do i get my TOTP Seed?](totpseed/README.md)
|
||||
@@ -0,0 +1,87 @@
|
||||
# Set up BML
|
||||
|
||||
Reset your Bank of Maldives authenticator to get a new OTP seed, then add that seed to Thijooree so it can generate your OTP codes.
|
||||
|
||||
> [!NOTE]
|
||||
> You need the BML app signed in, and a BML debit card with its expiry date and CVC to confirm who you are.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Want the same codes in another authenticator app too, such as Microsoft Authenticator or Google Authenticator? Add the secret key to that app **after step 5 and before step 7**. After you tap **Verify Code**, BML stops showing the secret key and you would have to reset again. See [Export from Microsoft Authenticator](04-export-microsoft.md) for the steps.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="25%">Step 1</th>
|
||||
<th width="25%">Step 2</th>
|
||||
<th width="25%">Step 3</th>
|
||||
<th width="25%">Step 4</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/bml_1.jpg" alt="BML app wallet screen with the profile icon highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_2.jpg" alt="Profile menu with Authenticator Setup highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_3.jpg" alt="Channel Settings screen with the Reset Authenticator button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_4.jpg" alt="Debit card verification form with the Authorize button highlighted" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Open your profile</b><br>
|
||||
In the BML app, tap the <b>profile icon</b> in the top-right corner of the Wallet screen.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Open Authenticator Setup</b><br>
|
||||
In the menu, under <b>Settings</b>, tap <b>Authenticator Setup</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Reset the authenticator</b><br>
|
||||
On the <b>Security</b> tab, tap <b>Reset Authenticator</b>. This replaces any authenticator app you used before.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Verify your debit card</b><br>
|
||||
Pick a debit card, enter its expiry month, expiry year and CVC, then tap <b>Authorize</b>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Step 5</th>
|
||||
<th>Step 6</th>
|
||||
<th>Step 7</th>
|
||||
<th>Done</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/bml_5.jpg" alt="QR code screen with the copy button next to the secret key highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_1.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_6.jpg" alt="BML screen with the OTP entered and the Verify Code button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_7.jpg" alt="Authenticator reset successfully message" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Copy the secret key</b><br>
|
||||
Below the QR code, tap the <b>copy button</b> next to the secret key. Keep this screen open, you will come back to it.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Add the seed to Thijooree</b><br>
|
||||
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. Tap the <b>Current OTP</b> box to copy the 6-digit code.<br><br>
|
||||
<i>Adding the key to another authenticator app? Do it now, before step 7.</i>
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Verify the code in BML</b><br>
|
||||
Go back to the BML app, paste the code into the 6-digit code field and tap <b>Verify Code</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>All done</b><br>
|
||||
BML shows <b>Authenticator reset successfully</b>. Thijooree now generates your BML OTP codes.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Log in to Thijooree
|
||||
|
||||
Once BML shows **Authenticator reset successfully**, go back to Thijooree:
|
||||
|
||||
1. Enter your BML **Username** and **Password**.
|
||||
2. Check that **OTP Seed (TOTP Secret)** still has the key you pasted in step 6.
|
||||
3. Tap **Login**.
|
||||
|
||||
> [!TIP]
|
||||
> The OTP changes every 30 seconds. If BML rejects the code, copy the current one from Thijooree again and verify straight away.
|
||||
|
||||
> [!WARNING]
|
||||
> The secret key gives full access to your OTP codes. Don't share it or screenshot it where others can see it.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Set up MIB
|
||||
|
||||
Maldives Islamic Bank doesn't let you reset your authenticator from the app. The only way to get a new OTP seed is to ask customer care, and then wait. And wait some more.
|
||||
|
||||
> [!NOTE]
|
||||
> You need patience, a working email address and a small amount of faith. Results may vary.
|
||||
|
||||
## How to do it
|
||||
|
||||
1. **Contact customer care**<br>
|
||||
Get in touch with MIB customer care and ask them to reset your authenticator and send you a new secret key.
|
||||
|
||||
2. **Perform the summoning ritual**<br>
|
||||
Light a candle, face the direction of the nearest MIB branch and chant *"please reply, please reply"* three times. Offering a cup of tea to the ticket gods is optional but recommended.
|
||||
|
||||
3. **Wait for the email**<br>
|
||||
MIB emails you the new secret key. Eventually. Check your inbox, then check your spam folder, then check your inbox again.
|
||||
|
||||
4. **Wait more**<br>
|
||||
Still nothing? This is normal. Refresh your inbox. Touch grass. Refresh your inbox again.
|
||||
|
||||
5. **Perform another ritual**<br>
|
||||
Repeat step 2, but with two candles this time. If it has been a few working days, a polite follow-up to customer care also works, and is less of a fire hazard.
|
||||
|
||||
6. **Add the seed to Thijooree**<br>
|
||||
Once the email arrives, copy the secret key from it. Open Thijooree, go to the **faisanet** sign-in screen and paste the key into **OTP Seed (TOTP Secret)**. The **Current OTP** appears below it.
|
||||
|
||||
## Log in to Thijooree
|
||||
|
||||
Once the key is in, in Thijooree:
|
||||
|
||||
1. Enter your MIB **Username** and **Password**.
|
||||
2. Tap **Login**.
|
||||
|
||||
> [!TIP]
|
||||
> Already have your MIB account in Google Authenticator or Bitwarden? Skip the rituals entirely and see [Export from Google Authenticator](03-export-googleauthenticator.md) or [Export from Bitwarden](05-export-bitwarden.md).
|
||||
|
||||
> [!WARNING]
|
||||
> The secret key gives full access to your OTP codes. Don't share it, and delete the email once you have logged in.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Export from Google Authenticator
|
||||
|
||||
Google Authenticator can export your accounts as a QR code. Take a screenshot of that QR code and load it into Thijooree to get the same OTP seed, without resetting anything with your bank.
|
||||
|
||||
> [!NOTE]
|
||||
> Exporting doesn't change your seed. Google Authenticator keeps working, and it shows the same codes as Thijooree.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Thijooree holds one seed per login. In step 3, select **only** the bank account you want to log in to on Thijooree.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="25%">Step 1</th>
|
||||
<th width="25%">Step 2</th>
|
||||
<th width="25%">Step 3</th>
|
||||
<th width="25%">Step 4</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/google_1.jpg" alt="Google Authenticator home screen with the menu button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_2.jpg" alt="Google Authenticator menu with Transfer codes highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_3.jpg" alt="Select codes screen with only MIB checked and the Next button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_4.jpg" alt="Scan this QR code screen showing the export QR code" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Open the menu</b><br>
|
||||
In Google Authenticator, tap the <b>menu button</b> (three lines) in the top-left corner.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Open Transfer codes</b><br>
|
||||
Tap <b>Transfer codes</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Select your bank account</b><br>
|
||||
Check <b>only</b> the bank account you want to log in to on Thijooree, then tap <b>Next</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Screenshot the QR code</b><br>
|
||||
Take a <b>screenshot</b> of the QR code. Keep this screen open, you will come back to it.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Step 5</th>
|
||||
<th>Step 6</th>
|
||||
<th>Step 7</th>
|
||||
<th>Step 8</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/thijooree_2.jpg" alt="Thijooree sign-in screen with the QR button next to the OTP seed field highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_3.jpg" alt="Thijooree QR scanner with the Pick image button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_4.jpg" alt="Photo picker with the QR code screenshot selected" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_5.jpg" alt="Google Authenticator Scan this QR code screen with the Next button highlighted" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Open the QR scanner</b><br>
|
||||
Open Thijooree and, on the sign-in screen, tap the <b>QR button</b> next to <b>OTP Seed (TOTP Secret)</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Pick an image</b><br>
|
||||
The QR code is on the same phone, so there is nothing to scan. Tap <b>Pick image</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Select the screenshot</b><br>
|
||||
Select the QR code screenshot from step 4. Thijooree fills in the OTP seed for you.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Finish the export</b><br>
|
||||
Go back to Google Authenticator and tap <b>Next</b> on the QR code screen.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Step 9</th>
|
||||
<th colspan="2">Step 10</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/google_6.jpg" alt="Remove your exported codes screen with Keep exported codes and Done highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_7.jpg" alt="Google Authenticator list with the MIB code highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_5.jpg" alt="Thijooree sign-in screen with the Current OTP highlighted, matching Google Authenticator" width="200"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Keep the exported codes</b><br>
|
||||
Select <b>Keep exported codes</b> and tap <b>Done</b>. Don't remove them, or the account disappears from Google Authenticator.
|
||||
</td>
|
||||
<td valign="top" colspan="2">
|
||||
<b>Check that the codes match</b><br>
|
||||
Compare the <b>Current OTP</b> in Thijooree with the code for the same account in Google Authenticator. They should be the same, because both use the same seed.
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Log in to Thijooree
|
||||
|
||||
Once the codes match, in Thijooree:
|
||||
|
||||
1. Enter your bank **Username** and **Password**.
|
||||
2. Tap **Login**.
|
||||
|
||||
> [!TIP]
|
||||
> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, go back to step 3 and check you selected the right account.
|
||||
|
||||
> [!WARNING]
|
||||
> The QR code screenshot holds your OTP seed. Delete it from your phone, and from any cloud photo backup, once you have logged in.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Export from Microsoft Authenticator
|
||||
|
||||
Microsoft Authenticator can't export TOTP seeds unless your phone is rooted, so you can't move an existing seed out of it.
|
||||
|
||||
Instead, reset your OTP seed with your bank to get a new secret key. Add that key to Thijooree, and to Microsoft Authenticator too if you want codes in both apps.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Resetting replaces the old seed. The existing BML entry in Microsoft Authenticator stops working, so you have to add the new key to it again.
|
||||
|
||||
## How to do it
|
||||
|
||||
1. Follow [Set up BML](01-setup-bml.md) up to **step 5**, where you copy the secret key.
|
||||
2. Add the key to Microsoft Authenticator using the steps below.
|
||||
3. Go back to [Set up BML](01-setup-bml.md) and continue from **step 6**.
|
||||
|
||||
> [!WARNING]
|
||||
> Add the key to Microsoft Authenticator **before step 7** (Verify Code). After you verify, BML stops showing the secret key and you would have to reset again.
|
||||
|
||||
## Add the key to Microsoft Authenticator
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="20%">Step 1</th>
|
||||
<th width="20%">Step 2</th>
|
||||
<th width="20%">Step 3</th>
|
||||
<th width="20%">Step 4</th>
|
||||
<th width="20%">Done</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/msauth_1.jpg" alt="Microsoft Authenticator home screen with the QR code button highlighted" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_2.jpg" alt="Scan QR Code screen with the Enter code manually button highlighted" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_3.jpg" alt="Add account screen with Other account highlighted" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_4.jpg" alt="Add account form with account name and secret key filled in" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_5.jpg" alt="Bank of Maldives account in the Microsoft Authenticator list showing a 6-digit code" width="160"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Add an account</b><br>
|
||||
Open Microsoft Authenticator and tap the <b>QR code button</b> in the bottom-right corner.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Enter the code manually</b><br>
|
||||
BML is on the same phone, so there is nothing to scan. Tap <b>Enter code manually</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Choose the account type</b><br>
|
||||
Tap <b>Other account (Google, Facebook, etc.)</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Paste the secret key</b><br>
|
||||
Enter an <b>Account Name</b> such as <i>Bank of Maldives</i>, paste the key from BML into <b>Secret Key</b> and tap <b>Finish</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Account added</b><br>
|
||||
The account now shows a 6-digit code. It matches the code in Thijooree because both use the same key.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Now go back to [Set up BML](01-setup-bml.md) and continue from **step 6**. In step 7 you can verify with the code from either Thijooree or Microsoft Authenticator.
|
||||
|
||||
For MIB, see [Set up MIB](02-setup-mib.md).
|
||||
@@ -0,0 +1,52 @@
|
||||
# Export from Bitwarden
|
||||
|
||||
Bitwarden stores the authenticator key of a login in plain text. Copy it from the login's edit screen and paste it into Thijooree to get the same OTP seed, without resetting anything with your bank.
|
||||
|
||||
> [!NOTE]
|
||||
> Copying the key doesn't change your seed. Bitwarden keeps working, and it shows the same codes as Thijooree.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="25%">Step 1</th>
|
||||
<th width="25%">Step 2</th>
|
||||
<th width="25%">Step 3</th>
|
||||
<th width="25%">Step 4</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/bitwarden_1.jpg" alt="Bitwarden View login screen for Bank of Maldives with the edit button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bitwarden_2.jpg" alt="Bitwarden Edit login screen with the copy button next to the Authenticator key highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_6.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bitwarden_3.jpg" alt="Bitwarden View login screen with the Authenticator key code highlighted, matching Thijooree" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Edit your bank login</b><br>
|
||||
In Bitwarden, open the login for your bank and tap the <b>edit button</b> in the bottom-right corner.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Copy the authenticator key</b><br>
|
||||
Tap the <b>copy button</b> next to <b>Authenticator key</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Add the seed to Thijooree</b><br>
|
||||
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. The <b>Current OTP</b> appears below it.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Check that the codes match</b><br>
|
||||
Go back to Bitwarden, close the edit screen without saving and compare the <b>Authenticator key</b> code with the <b>Current OTP</b> in Thijooree. They should be the same, because both use the same seed.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Log in to Thijooree
|
||||
|
||||
Once the codes match, in Thijooree:
|
||||
|
||||
1. Enter your bank **Username** and **Password**.
|
||||
2. Tap **Login**.
|
||||
|
||||
> [!TIP]
|
||||
> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, copy the key from Bitwarden again and make sure you opened the right login.
|
||||
|
||||
> [!WARNING]
|
||||
> The authenticator key gives full access to your OTP codes. Don't share it or paste it anywhere else.
|
||||
@@ -0,0 +1,28 @@
|
||||
# TOTP Seed
|
||||
|
||||
## What is a TOTP seed?
|
||||
|
||||
A TOTP (Time-based One-Time Password) seed is the secret key your bank gives you when you set up its authenticator. It looks like a string of capital letters and numbers such as `JBSWY3DPEHPK3PXP`, or comes inside a QR code as an `otpauth://` link.
|
||||
|
||||
## Thijooree needs the seed to:
|
||||
- Enable OTP-less Transactions.
|
||||
- Show your OTP codes (to use official bank app or website along with Thijooree.)
|
||||
|
||||
To keep transactions secure, you can have Thijooree ask for your biometrics instead of an OTP. \
|
||||
Keep your seed private: anyone who has it can make your OTP codes.
|
||||
|
||||
## How do I get my TOTP seed?
|
||||
|
||||
You get your seed in one of two ways:
|
||||
|
||||
### Setup
|
||||
|
||||
| [<img src="../../../logos/bml_logo.png" alt="BML" height="64">](01-setup-bml.md) | [<img src="../../../logos/mib_logo.png" alt="MIB" height="64">](02-setup-mib.md) |
|
||||
|:---:|:---:|
|
||||
| [1. Set up BML](01-setup-bml.md) | [2. Set up MIB](02-setup-mib.md) |
|
||||
|
||||
### Export
|
||||
|
||||
| [<img src="../../../logos/google_authenticator_logo.svg" alt="Google Authenticator" height="64">](03-export-googleauthenticator.md) | [<img src="../../../logos/microsoft_authenticator_logo.png" alt="Microsoft Authenticator" height="64">](04-export-microsoft.md) | [<img src="../../../logos/bitwarden_logo.png" alt="Bitwarden" height="64">](05-export-bitwarden.md) |
|
||||
|:---:|:---:|:---:|
|
||||
| [3. Export from Google Authenticator](03-export-googleauthenticator.md) | [4. Export from Microsoft Authenticator](04-export-microsoft.md) | [5. Export from Bitwarden](05-export-bitwarden.md) |
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1 @@
|
||||
- Export and Update OTP seeds
|
||||
@@ -0,0 +1 @@
|
||||
- New setting to always show recipt full screen
|
||||
@@ -0,0 +1 @@
|
||||
- Pay with BML Transaction ID (On BML Pay supported transactions"
|
||||
@@ -0,0 +1,4 @@
|
||||
- Fixed payments restarting or showing as failed when rotating or resizing the app, and the To box flickering when loading a merchant
|
||||
- Transfer screen now keeps your form and merchant when switching tabs or changing theme/language, and waits for payments to finish before applying them
|
||||
- Fixed-amount merchant QR or Gateway now show the amount clearly with a lock icon instead of greying it out
|
||||
- App lock icon does not go behind navigation bar in landscape mode
|
||||