forked from thijooree/android
new feature: long press OTP card in OTP codes page to export or update seed
This commit is contained in:
@@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
.build()
|
||||
|
||||
/** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */
|
||||
fun updateOtpSeed(otpSeed: String) {
|
||||
if (storedOtpSeed != null) storedOtpSeed = otpSeed
|
||||
}
|
||||
|
||||
// ─── Public entry point ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -19,14 +30,22 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding
|
||||
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
|
||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.OtpauthParser
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
class OtpFragment : Fragment() {
|
||||
@@ -34,7 +53,35 @@ class OtpFragment : Fragment() {
|
||||
private var _binding: FragmentOtpBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private data class OtpEntry(val bank: String, val name: String?, val seed: String)
|
||||
private data class OtpEntry(
|
||||
val bank: String, val loginId: String, val account: String, val name: String?, val seed: String
|
||||
)
|
||||
|
||||
private val entries = mutableListOf<OtpEntry>()
|
||||
private var adapter: OtpAdapter? = null
|
||||
|
||||
/** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */
|
||||
private var scanTarget: android.widget.EditText? = null
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val target = scanTarget ?: return@registerForActivityResult
|
||||
val found = OtpauthParser.parse(raw)
|
||||
when {
|
||||
found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||
found.size == 1 -> target.setText(found[0].secret)
|
||||
else -> {
|
||||
val labels = found.map { e ->
|
||||
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||
}.toTypedArray()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Choose account")
|
||||
.setItems(labels) { _, i -> target.setText(found[i].secret) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||
@@ -56,6 +103,8 @@ class OtpFragment : Fragment() {
|
||||
)
|
||||
update(b, entry.seed)
|
||||
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
// Long-press opens the seed menu (export / update)
|
||||
b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true }
|
||||
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
|
||||
}
|
||||
@@ -91,6 +140,170 @@ class OtpFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSeedMenu(anchor: View, position: Int) {
|
||||
if (position == RecyclerView.NO_POSITION) return
|
||||
val popup = PopupMenu(anchor.context, anchor)
|
||||
popup.menu.add(0, 1, 0, "Export seed")
|
||||
popup.menu.add(0, 2, 1, "Update seed")
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false
|
||||
when (item.itemId) {
|
||||
1 -> showExportDialog(entry)
|
||||
2 -> showUpdateDialog(position)
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}"
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun showExportDialog(entry: OtpEntry) {
|
||||
val ctx = requireContext()
|
||||
val d = DialogOtpExportSeedBinding.inflate(layoutInflater)
|
||||
val uri = OtpauthParser.buildUri(entry.bank, entry.seed)
|
||||
d.tvSeed.text = entry.seed.chunked(4).joinToString(" ")
|
||||
renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) }
|
||||
d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") }
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(entryTitle(entry))
|
||||
.setView(d.root)
|
||||
.setPositiveButton("Done", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun renderQr(content: String, size: Int): Bitmap? = try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||
)
|
||||
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE }
|
||||
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
/** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */
|
||||
private fun copySensitive(context: Context, text: String, message: String) {
|
||||
val clip = ClipData.newPlainText("OTP seed", text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
clip.description.extras = PersistableBundle().apply {
|
||||
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||
}
|
||||
}
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun showUpdateDialog(position: Int) {
|
||||
val entry = entries.getOrNull(position) ?: return
|
||||
val ctx = requireContext()
|
||||
val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater)
|
||||
d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " +
|
||||
"can't be recovered. If you might still need it, export it first."
|
||||
|
||||
var newSeed: String? = null
|
||||
// Same behaviour as the sign-in screen's preview card
|
||||
val preview = d.cardOtp
|
||||
preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") }
|
||||
fun refreshPreview() {
|
||||
val seed = newSeed
|
||||
try {
|
||||
if (seed == null) throw IllegalArgumentException()
|
||||
preview.tvOtpCode.text = Totp.generate(seed)
|
||||
preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
preview.otpTimer.max = 30
|
||||
preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||
preview.root.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
preview.root.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle("Update seed · ${entry.bank}")
|
||||
.setView(d.root)
|
||||
.setPositiveButton("Replace", null)
|
||||
.setNegativeButton("Cancel", null)
|
||||
.create()
|
||||
|
||||
fun validate() {
|
||||
val raw = d.etNewSeed.text?.toString().orEmpty()
|
||||
newSeed = OtpauthParser.resolveSecret(raw)
|
||||
d.tilNewSeed.error = when {
|
||||
raw.isBlank() -> null
|
||||
newSeed == null -> "Not a valid TOTP seed"
|
||||
newSeed == entry.seed -> "This is already the current seed"
|
||||
else -> null
|
||||
}
|
||||
if (newSeed == entry.seed) newSeed = null
|
||||
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null
|
||||
refreshPreview()
|
||||
}
|
||||
|
||||
d.etNewSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) = validate()
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
d.btnScanNewSeed.setOnClickListener {
|
||||
scanTarget = d.etNewSeed
|
||||
qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
val ticker = viewLifecycleOwner.lifecycleScope.launch {
|
||||
while (isActive) { refreshPreview(); delay(1_000) }
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
ticker.cancel()
|
||||
if (scanTarget === d.etNewSeed) scanTarget = null
|
||||
}
|
||||
dialog.setOnShowListener {
|
||||
val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
replace.isEnabled = false
|
||||
replace.setOnClickListener {
|
||||
val seed = newSeed ?: return@setOnClickListener
|
||||
confirmReplace(entry) {
|
||||
saveSeed(position, seed)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Delete old seed?")
|
||||
.setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " +
|
||||
"recovered afterwards.")
|
||||
.setPositiveButton("Replace") { _, _ -> onConfirm() }
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun saveSeed(position: Int, seed: String) {
|
||||
val entry = entries.getOrNull(position) ?: return
|
||||
val store = CredentialStore(requireContext())
|
||||
when (entry.bank) {
|
||||
"MIB" -> {
|
||||
store.updateMibOtpSeed(entry.loginId, seed)
|
||||
// The live flow keeps the seed in memory for silent re-login
|
||||
(requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed)
|
||||
}
|
||||
"BML" -> store.updateBmlOtpSeed(entry.loginId, seed)
|
||||
}
|
||||
entries[position] = entry.copy(seed = seed)
|
||||
adapter?.notifyItemChanged(position)
|
||||
Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||
val code = text.toString().replace(" ", "")
|
||||
if (code.isEmpty() || code.contains('-')) return
|
||||
@@ -115,16 +328,19 @@ class OtpFragment : Fragment() {
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||
val name = store.loadMibFullName(loginId)
|
||||
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("mib", loginId) to
|
||||
OtpEntry("MIB", loginId, creds.username, name, creds.otpSeed))
|
||||
}
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("bml", loginId) to
|
||||
OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||
}
|
||||
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
|
||||
entries.clear()
|
||||
entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second })
|
||||
|
||||
val adapter = OtpAdapter(entries)
|
||||
val adapter = OtpAdapter(entries).also { this.adapter = it }
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||
@@ -147,8 +363,7 @@ class OtpFragment : Fragment() {
|
||||
mobile = profile.mobile,
|
||||
enrolled = profile.enrolled
|
||||
))
|
||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
@@ -168,8 +383,7 @@ class OtpFragment : Fragment() {
|
||||
idCard = info.idCard,
|
||||
birthdate = info.birthdate
|
||||
))
|
||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
@@ -192,6 +406,8 @@ class OtpFragment : Fragment() {
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
adapter = null
|
||||
scanTarget = null
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
binding.cardOtp.setOnClickListener {
|
||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||
binding.cardOtp.root.setOnClickListener {
|
||||
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
|
||||
if (code.isNotEmpty()) {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
@@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() {
|
||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||
val seed = resolveOtpSeed(otpSeedRaw)
|
||||
if (seed.isEmpty()) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
val password = binding.etPassword.text.toString()
|
||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -211,13 +211,13 @@ class CredentialsFragment : Fragment() {
|
||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||
val remaining = 30 - secondsInPeriod
|
||||
|
||||
binding.tvOtpCode.text = otp
|
||||
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
binding.otpTimer.max = 30
|
||||
binding.otpTimer.progress = remaining
|
||||
binding.cardOtp.visibility = View.VISIBLE
|
||||
binding.cardOtp.tvOtpCode.text = otp
|
||||
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
binding.cardOtp.otpTimer.max = 30
|
||||
binding.cardOtp.otpTimer.progress = remaining
|
||||
binding.cardOtp.root.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,11 @@ class CredentialStore(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||
fun updateMibOtpSeed(loginId: String, otpSeed: String) {
|
||||
prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||
}
|
||||
|
||||
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||
@@ -230,6 +235,11 @@ class CredentialStore(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||
fun updateBmlOtpSeed(loginId: String, otpSeed: String) {
|
||||
prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||
}
|
||||
|
||||
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||
|
||||
@@ -13,6 +13,27 @@ object OtpauthParser {
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw
|
||||
* secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed.
|
||||
*/
|
||||
fun resolveSecret(input: String): String? {
|
||||
val raw = input.trim()
|
||||
val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw
|
||||
val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase()
|
||||
if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null
|
||||
// Too short to be a real seed, e.g. a pasted 6-digit OTP code
|
||||
if (clean.length < 8) return null
|
||||
return clean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shortest otpauth://totp link authenticator apps accept: just a label and the
|
||||
* secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out.
|
||||
*/
|
||||
fun buildUri(label: String, secret: String): String =
|
||||
"otpauth://totp/" + Uri.encode(label) + "?secret=$secret"
|
||||
|
||||
private fun parseStandard(raw: String): OtpEntry? {
|
||||
val uri = Uri.parse(raw)
|
||||
val secret = uri.getQueryParameter("secret") ?: return null
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="Scan this with your authenticator app, or copy the seed. Please keep it private, since anyone who has it can generate your OTP codes."
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<!-- Always black-on-white so scanners read it in dark mode too -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@android:color/white"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivSeedQr"
|
||||
android:layout_width="220dp"
|
||||
android:layout_height="220dp"
|
||||
android:layout_margin="12dp"
|
||||
android:contentDescription="OTP seed QR code" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:fontFamily="monospace"
|
||||
android:gravity="center"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textIsSelectable="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCopySeed"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="Copy seed"
|
||||
app:icon="@drawable/ic_copy" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="8dp">
|
||||
|
||||
<!-- Warning: the old seed is overwritten -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||
app:cardCornerRadius="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_info"
|
||||
app:tint="?attr/colorOnErrorContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSeedWarning"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnErrorContainer" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilNewSeed"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="New OTP seed"
|
||||
app:helperText="Base32 secret or otpauth:// link">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etNewSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textNoSuggestions|textVisiblePassword"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanNewSeed"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/scan_otp_qr"
|
||||
android:tooltipText="@string/scan_otp_qr"
|
||||
app:icon="@drawable/ic_qr_scan" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Live preview of the new seed's code, same card as the sign-in screen -->
|
||||
<include
|
||||
android:id="@+id/cardOtp"
|
||||
layout="@layout/view_otp_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -130,89 +130,13 @@
|
||||
android:maxLength="6" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
<include
|
||||
android:id="@+id/cardOtp"
|
||||
layout="@layout/view_otp_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="invisible"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current OTP"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.15"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="end"
|
||||
android:alpha="0.7">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Next"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNextOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpTimer"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
app:indicatorSize="32dp"
|
||||
app:trackThickness="3dp"
|
||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||
app:trackColor="?attr/colorSecondaryContainer"
|
||||
android:indeterminate="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvError"
|
||||
|
||||
@@ -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>
|
||||
@@ -21,6 +21,29 @@ Each card shows:
|
||||
|
||||
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
|
||||
|
||||
---
|
||||
|
||||
## Seed Actions
|
||||
|
||||
Long-pressing a card opens a menu with:
|
||||
|
||||
### Export seed
|
||||
|
||||
A dialog titled `{bank} · {name}` showing:
|
||||
- A QR code of a minimal `otpauth://totp/{BANK}?secret=…` link (e.g. `otpauth://totp/BML?secret=…`), always drawn black-on-white so it scans in dark mode. No username or issuer is included, and algorithm, digits and period are left out because SHA1, 6 and 30s are the spec defaults. Export is single-account only; `otpauth-migration://` is supported for import but never produced.
|
||||
- The Base32 seed in groups of 4 (selectable text)
|
||||
- A **Copy seed** button. The copy is flagged `EXTRA_IS_SENSITIVE`, so Android 13+ hides the value in the clipboard preview.
|
||||
|
||||
### Update seed
|
||||
|
||||
Replaces the stored seed for that login, e.g. after re-enrolling the authenticator with the bank.
|
||||
- A red warning banner explains that the old seed is deleted permanently.
|
||||
- The new seed can be typed or pasted (raw Base32 or an `otpauth://` link) or scanned with the QR button. Scans that contain several accounts (`otpauth-migration://`) ask which one to use.
|
||||
- Once the input is a valid seed, a live preview shows its current and next code with a countdown so the user can check it against the bank before saving. The preview is the same card as the sign-in screen (`view_otp_preview.xml`, shared by both), and tapping it copies the code. Input that isn't valid Base32, is shorter than 8 characters (such as a pasted 6-digit code), or matches the current seed disables **Replace**.
|
||||
- **Replace** asks for confirmation ("Delete old seed?"). Confirming calls `CredentialStore.updateMibOtpSeed()` / `updateBmlOtpSeed()`, which overwrite only the encrypted seed. For MIB it also calls `MibLoginFlow.updateOtpSeed()` so silent re-login uses the new seed.
|
||||
|
||||
The username, password and sessions are not touched. Other places that need an OTP (transfers, QR pay, pay with card) read the seed from `CredentialStore` each time, so they pick up the new seed immediately.
|
||||
|
||||
### Algorithm
|
||||
|
||||
Standard RFC 6238 TOTP:
|
||||
@@ -58,7 +81,7 @@ The OTP screen is informational — the user copies the displayed code manually
|
||||
|
||||
## Security
|
||||
|
||||
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports.
|
||||
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. They leave the app only when the user chooses **Export seed**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user