From 369c6e4a373fc5e0abbedd9c5c324e2a16b944a1 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 25 Sep 2026 06:18:09 +0500 Subject: [PATCH] redegsigned OTP page and show next TOTP code --- .../sh/sar/basedbank/ui/home/OtpFragment.kt | 61 ++++-- .../basedbank/ui/login/CredentialsFragment.kt | 1 + .../main/java/sh/sar/basedbank/util/Totp.kt | 5 +- .../main/res/layout/fragment_credentials.xml | 26 +++ app/src/main/res/layout/fragment_otp.xml | 37 +++- app/src/main/res/layout/item_otp_card.xml | 174 ++++++++++++++---- docs/thijooree/10-otp-screen.md | 19 +- 7 files changed, 258 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt index 27205c5..fd233dd 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/OtpFragment.kt @@ -18,7 +18,9 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import com.google.android.material.color.MaterialColors 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 @@ -32,7 +34,7 @@ class OtpFragment : Fragment() { private var _binding: FragmentOtpBinding? = null private val binding get() = _binding!! - private data class OtpEntry(val label: String, val seed: String) + private data class OtpEntry(val bank: String, val name: String?, val seed: String) private inner class OtpAdapter(private val entries: List) : RecyclerView.Adapter() { @@ -45,18 +47,17 @@ class OtpFragment : Fragment() { VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)) override fun onBindViewHolder(holder: VH, position: Int) { - holder.b.tvOtpLabel.text = entries[position].label - update(holder.b, entries[position].seed) - holder.b.root.setOnClickListener { - val code = holder.b.tvOtpCode.text.toString().replace(" ", "") - if (code.isNotEmpty()) { - val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show() - } - } - } + val entry = entries[position] + val b = holder.b + b.tvOtpBank.text = entry.bank + b.tvOtpLabel.text = entry.name ?: "Authenticator" + b.ivBankLogo.setImageResource( + if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo + ) + update(b, entry.seed) + b.root.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") } } fun tick() { @@ -71,9 +72,32 @@ class OtpFragment : Fragment() { val secondsInPeriod = (epochSeconds % 30).toInt() val remaining = 30 - secondsInPeriod val code = try { Totp.generate(seed) } catch (_: Exception) { "------" } + val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" } b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}" + b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}" b.otpProgress.progress = remaining - b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}" + b.tvOtpCountdown.text = remaining.toString() + + // Turn the ring and code red in the last few seconds of the window + val expiring = remaining <= 5 + val accent = MaterialColors.getColor(b.root, + if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary) + b.otpProgress.setIndicatorColor(accent) + b.tvOtpCode.setTextColor(accent) + b.tvOtpCountdown.setTextColor( + if (expiring) accent + else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant) + ) + } + } + + private fun copyCode(context: Context, text: CharSequence, message: String) { + val code = text.toString().replace(" ", "") + if (code.isEmpty() || code.contains('-')) return + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } @@ -91,18 +115,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(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) + tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", 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(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed)) + tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed)) } val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList() val adapter = OtpAdapter(entries) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter + binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE // Fetch real names in background if not yet cached, then refresh labels viewLifecycleOwner.lifecycleScope.launch { @@ -124,7 +149,7 @@ class OtpFragment : Fragment() { )) val seed = store.loadMibCredentials(loginId)?.otpSeed val idx = entries.indexOfFirst { it.seed == seed } - if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true } + if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true } } } } @@ -145,7 +170,7 @@ class OtpFragment : Fragment() { )) val seed = store.loadBmlCredentials(loginId)?.otpSeed val idx = entries.indexOfFirst { it.seed == seed } - if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true } + if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true } } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt index 4a31161..886cabd 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt @@ -212,6 +212,7 @@ class CredentialsFragment : Fragment() { 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 diff --git a/app/src/main/java/sh/sar/basedbank/util/Totp.kt b/app/src/main/java/sh/sar/basedbank/util/Totp.kt index beaa358..43d9735 100644 --- a/app/src/main/java/sh/sar/basedbank/util/Totp.kt +++ b/app/src/main/java/sh/sar/basedbank/util/Totp.kt @@ -9,10 +9,11 @@ object Totp { /** * Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226). * Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps. + * [periodOffset] shifts the time window, e.g. 1 yields the next code. */ - fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String { + fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30, periodOffset: Long = 0): String { val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", "")) - val counter = System.currentTimeMillis() / 1000L / periodSeconds + val counter = System.currentTimeMillis() / 1000L / periodSeconds + periodOffset val otp = hotp(key, counter, digits) return otp.toString().padStart(digits, '0') } diff --git a/app/src/main/res/layout/fragment_credentials.xml b/app/src/main/res/layout/fragment_credentials.xml index a5265fa..feeed55 100644 --- a/app/src/main/res/layout/fragment_credentials.xml +++ b/app/src/main/res/layout/fragment_credentials.xml @@ -175,6 +175,32 @@ + + + + + + + + - + android:layout_height="match_parent"> - + + + + + + + + + diff --git a/app/src/main/res/layout/item_otp_card.xml b/app/src/main/res/layout/item_otp_card.xml index 97f3235..a959ccc 100644 --- a/app/src/main/res/layout/item_otp_card.xml +++ b/app/src/main/res/layout/item_otp_card.xml @@ -2,55 +2,163 @@ + app:cardCornerRadius="24dp"> + android:paddingHorizontal="20dp" + android:paddingTop="16dp" + android:paddingBottom="12dp"> - - - - - + + android:orientation="horizontal" + android:gravity="center_vertical"> - + + + + + + + + + + + + + + + + + + + + + + android:layout_marginTop="12dp" + android:orientation="horizontal" + android:gravity="center_vertical"> + + + +