Compare commits
6
Commits
41e7bc70e9
...
v1.0.31
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e7f329a4b
|
||
|
|
2082e8fd0c
|
||
|
|
97a0cb218f
|
||
|
|
0158d6dcd8
|
||
|
|
320eaa2ffb
|
||
|
|
1886113ae7
|
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 29
|
versionCode = 32
|
||||||
versionName = "1.0.28"
|
versionName = "1.0.31"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
.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 ───────────────────────────────────────────────────
|
// ─── Public entry point ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -528,6 +528,10 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
private fun routeSharedQrText(text: String) {
|
||||||
val store = CredentialStore(this)
|
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)
|
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||||
if (bmlTarget != null) {
|
if (bmlTarget != null) {
|
||||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PersistableBundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -19,14 +30,22 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import com.google.android.material.color.MaterialColors
|
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.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
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.FragmentOtpBinding
|
||||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.OtpauthParser
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class OtpFragment : Fragment() {
|
class OtpFragment : Fragment() {
|
||||||
@@ -34,7 +53,35 @@ class OtpFragment : Fragment() {
|
|||||||
private var _binding: FragmentOtpBinding? = null
|
private var _binding: FragmentOtpBinding? = null
|
||||||
private val binding get() = _binding!!
|
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>) :
|
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||||
@@ -56,6 +103,8 @@ class OtpFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
update(b, entry.seed)
|
update(b, entry.seed)
|
||||||
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
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.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||||
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next 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) {
|
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||||
val code = text.toString().replace(" ", "")
|
val code = text.toString().replace(" ", "")
|
||||||
if (code.isEmpty() || code.contains('-')) return
|
if (code.isEmpty() || code.contains('-')) return
|
||||||
@@ -115,16 +328,19 @@ class OtpFragment : Fragment() {
|
|||||||
for (loginId in store.getMibLoginIds()) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
val name = store.loadMibFullName(loginId)
|
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()) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
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.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||||
@@ -147,8 +363,7 @@ class OtpFragment : Fragment() {
|
|||||||
mobile = profile.mobile,
|
mobile = profile.mobile,
|
||||||
enrolled = profile.enrolled
|
enrolled = profile.enrolled
|
||||||
))
|
))
|
||||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,8 +383,7 @@ class OtpFragment : Fragment() {
|
|||||||
idCard = info.idCard,
|
idCard = info.idCard,
|
||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
))
|
))
|
||||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +406,8 @@ class OtpFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
adapter = null
|
||||||
|
scanTarget = null
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
val isDark = prefs.getString("theme", "system") == "dark"
|
val isDark = prefs.getString("theme", "system") == "dark"
|
||||||
updatePitchBlackState(isDark)
|
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
|
// Accent color
|
||||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||||
binding.accentToggle.check(when (savedPreset) {
|
binding.accentToggle.check(when (savedPreset) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
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.BankAccount
|
||||||
import sh.sar.basedbank.api.models.BankContact
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
@@ -177,13 +178,17 @@ class TransferFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return
|
if (result.resultCode != Activity.RESULT_OK) return
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: 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
|
// BML card/gateway/POS QR — hand off to dedicated payment screen
|
||||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (bmlTarget != null) {
|
if (bmlTarget != null) {
|
||||||
val fromCard = selectedAccount?.takeIf {
|
openBmlQr(bmlTarget)
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +252,7 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
private const val ARG_BML_QR_URL = "bml_qr_url"
|
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||||
private const val ARG_AUTO_SCAN = "auto_scan"
|
private const val ARG_AUTO_SCAN = "auto_scan"
|
||||||
|
private const val ARG_BML_TXN_ID = "bml_txn_id"
|
||||||
|
|
||||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||||
@@ -259,6 +265,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 {
|
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||||
}
|
}
|
||||||
@@ -381,6 +392,11 @@ class TransferFragment : Fragment() {
|
|||||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.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 { bmlHandler().lookupQrMerchant(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) {
|
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||||
launchQrScanner()
|
launchQrScanner()
|
||||||
@@ -765,7 +781,40 @@ class TransferFragment : Fragment() {
|
|||||||
setupContactDropdown()
|
setupContactDropdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reopens the Transfer screen in BML merchant-QR mode, keeping a selected BML card as the source. */
|
||||||
|
private fun openBmlQr(bmlTarget: String) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
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)
|
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
||||||
if (selectedAccount?.bank == "MFAISA") {
|
if (selectedAccount?.bank == "MFAISA") {
|
||||||
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
||||||
|
|||||||
@@ -168,6 +168,15 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||||
saveReceipt()
|
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 ──────────────────────────────────────────────────────────
|
// ── Data binding ──────────────────────────────────────────────────────────
|
||||||
@@ -483,11 +492,11 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFullScreenReceipt() {
|
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
if (bank == "BML") { showBmlFullScreenReceipt(); return }
|
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
|
||||||
if (bank == "MIB") { showMibFullScreenReceipt(); return }
|
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
|
||||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
|
||||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
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_MASK) ==
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||||
|
if (closePageOnDismiss) closeReceiptPage()
|
||||||
}
|
}
|
||||||
dialog.show()
|
dialog.show()
|
||||||
dialog.window?.let { win ->
|
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.
|
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
||||||
* Follows the app theme (light/dark).
|
* Follows the app theme (light/dark).
|
||||||
*/
|
*/
|
||||||
private fun showBmlFullScreenReceipt() {
|
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||||
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||||
@@ -557,6 +567,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
page.btnBack.setOnClickListener { dialog.dismiss() }
|
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||||
|
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
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,
|
* (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.
|
* 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 ctx = requireContext()
|
||||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||||
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||||
@@ -607,6 +618,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
))
|
))
|
||||||
|
|
||||||
page.btnClose.setOnClickListener { dialog.dismiss() }
|
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||||
|
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||||
|
|
||||||
@@ -647,6 +659,12 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
dialog.show()
|
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) {
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
for (tv in views) {
|
for (tv in views) {
|
||||||
tv.setOnLongClickListener {
|
tv.setOnLongClickListener {
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() {
|
|||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.cardOtp.setOnClickListener {
|
binding.cardOtp.root.setOnClickListener {
|
||||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
|
||||||
if (code.isNotEmpty()) {
|
if (code.isNotEmpty()) {
|
||||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
@@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() {
|
|||||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
val seed = resolveOtpSeed(otpSeedRaw)
|
val seed = resolveOtpSeed(otpSeedRaw)
|
||||||
if (seed.isEmpty()) {
|
if (seed.isEmpty()) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -211,13 +211,13 @@ class CredentialsFragment : Fragment() {
|
|||||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
|
|
||||||
binding.tvOtpCode.text = otp
|
binding.cardOtp.tvOtpCode.text = otp
|
||||||
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||||
binding.otpTimer.max = 30
|
binding.cardOtp.otpTimer.max = 30
|
||||||
binding.otpTimer.progress = remaining
|
binding.cardOtp.otpTimer.progress = remaining
|
||||||
binding.cardOtp.visibility = View.VISIBLE
|
binding.cardOtp.root.visibility = View.VISIBLE
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.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? {
|
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||||
@@ -230,6 +235,11 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.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? {
|
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||||
|
|||||||
@@ -13,6 +13,27 @@ object OtpauthParser {
|
|||||||
else -> emptyList()
|
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? {
|
private fun parseStandard(raw: String): OtpEntry? {
|
||||||
val uri = Uri.parse(raw)
|
val uri = Uri.parse(raw)
|
||||||
val secret = uri.getQueryParameter("secret") ?: return null
|
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" />
|
android:maxLength="6" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
<include
|
||||||
android:id="@+id/cardOtp"
|
android:id="@+id/cardOtp"
|
||||||
|
layout="@layout/view_otp_preview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginBottom="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>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvError"
|
android:id="@+id/tvError"
|
||||||
|
|||||||
@@ -304,6 +304,34 @@
|
|||||||
|
|
||||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
</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>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</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="theme_dark">Dark</string>
|
||||||
<string name="settings_pitch_black">Pitch Black</string>
|
<string name="settings_pitch_black">Pitch Black</string>
|
||||||
<string name="settings_accent_color">Accent Color</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_blue">Blue</string>
|
||||||
<string name="accent_orange">Red</string>
|
<string name="accent_orange">Red</string>
|
||||||
<string name="accent_green">Green</string>
|
<string name="accent_green">Green</string>
|
||||||
@@ -294,6 +296,7 @@
|
|||||||
<!-- BML QR Pay -->
|
<!-- BML QR Pay -->
|
||||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||||
<string name="bml_qr_lookup_failed">Could not load merchant details</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_payment_success">Payment Successful</string>
|
||||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
### Algorithm
|
||||||
|
|
||||||
Standard RFC 6238 TOTP:
|
Standard RFC 6238 TOTP:
|
||||||
@@ -58,7 +81,7 @@ The OTP screen is informational — the user copies the displayed code manually
|
|||||||
|
|
||||||
## Security
|
## 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 @@
|
|||||||
|
- 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"
|
||||||
Reference in New Issue
Block a user