new feature: long press OTP card in OTP codes page to export or update seed

This commit is contained in:
2026-09-26 20:43:14 +05:00
parent 41e7bc70e9
commit 1886113ae7
10 changed files with 543 additions and 99 deletions
@@ -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 ───────────────────────────────────────────────────
/** /**
@@ -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
} }
} }
@@ -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"
@@ -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>
+24 -1
View File
@@ -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**.
--- ---