redegsigned OTP page and show next TOTP code
Auto Tag on Version Change / check-version (push) Successful in 3s

This commit is contained in:
2026-09-25 06:18:09 +05:00
parent fbedc631c5
commit 369c6e4a37
7 changed files with 258 additions and 65 deletions
@@ -18,7 +18,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive 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 sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
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
@@ -32,7 +34,7 @@ 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 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<OtpEntry>) : private inner class OtpAdapter(private val entries: List<OtpEntry>) :
RecyclerView.Adapter<OtpAdapter.VH>() { RecyclerView.Adapter<OtpAdapter.VH>() {
@@ -45,18 +47,17 @@ class OtpFragment : Fragment() {
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)) VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) { override fun onBindViewHolder(holder: VH, position: Int) {
holder.b.tvOtpLabel.text = entries[position].label val entry = entries[position]
update(holder.b, entries[position].seed) val b = holder.b
holder.b.root.setOnClickListener { b.tvOtpBank.text = entry.bank
val code = holder.b.tvOtpCode.text.toString().replace(" ", "") b.tvOtpLabel.text = entry.name ?: "Authenticator"
if (code.isNotEmpty()) { b.ivBankLogo.setImageResource(
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) )
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { update(b, entry.seed)
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show() 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() { fun tick() {
@@ -71,9 +72,32 @@ class OtpFragment : Fragment() {
val secondsInPeriod = (epochSeconds % 30).toInt() val secondsInPeriod = (epochSeconds % 30).toInt()
val remaining = 30 - secondsInPeriod val remaining = 30 - secondsInPeriod
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" } 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.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
b.otpProgress.progress = remaining 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()) { 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(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()) { 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(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 entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
val adapter = OtpAdapter(entries) val adapter = OtpAdapter(entries)
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
// Fetch real names in background if not yet cached, then refresh labels // Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -124,7 +149,7 @@ class OtpFragment : Fragment() {
)) ))
val seed = store.loadMibCredentials(loginId)?.otpSeed val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed } 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 seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed } 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 }
} }
} }
} }
@@ -212,6 +212,7 @@ class CredentialsFragment : Fragment() {
val remaining = 30 - secondsInPeriod val remaining = 30 - secondsInPeriod
binding.tvOtpCode.text = otp binding.tvOtpCode.text = otp
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
binding.otpTimer.max = 30 binding.otpTimer.max = 30
binding.otpTimer.progress = remaining binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE binding.cardOtp.visibility = View.VISIBLE
@@ -9,10 +9,11 @@ object Totp {
/** /**
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226). * 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. * 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 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) val otp = hotp(key, counter, digits)
return otp.toString().padStart(digits, '0') return otp.toString().padStart(digits, '0')
} }
@@ -175,6 +175,32 @@
</LinearLayout> </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 <com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpTimer" android:id="@+id/otpTimer"
android:layout_width="32dp" android:layout_width="32dp"
+33 -4
View File
@@ -1,15 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="16dp"
android:clipToPadding="false" /> android:clipToPadding="false" />
</LinearLayout> <LinearLayout
android:id="@+id/emptyState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginBottom="12dp"
android:alpha="0.6"
android:importantForAccessibility="no"
android:src="@drawable/ic_nav_otp"
android:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="No OTP codes yet.\nAdd an MIB or BML login to see its codes here."
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</FrameLayout>
+130 -22
View File
@@ -2,56 +2,164 @@
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:cardCornerRadius="16dp" app:cardCornerRadius="24dp">
app:cardElevation="2dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:paddingHorizontal="20dp"
android:paddingTop="16dp"
android:paddingBottom="12dp">
<!-- Header: logo, bank + holder name, countdown ring -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivBankLogo"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
android:scaleType="fitCenter"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvOtpBank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView <TextView
android:id="@+id/tvOtpLabel" android:id="@+id/tvOtpLabel"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelMedium" android:ellipsize="end"
android:textColor="?attr/colorOnSurfaceVariant" android:maxLines="1"
android:layout_marginBottom="8dp" /> android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView </LinearLayout>
android:id="@+id/tvOtpCode"
<FrameLayout
android:layout_width="40dp"
android:layout_height="40dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpProgress"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceDisplaySmall" android:layout_gravity="center"
android:textColor="?attr/colorPrimary" android:indeterminate="false"
android:fontFamily="monospace"
android:letterSpacing="0.15"
android:layout_marginBottom="16dp" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/otpProgress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="30" android:max="30"
app:trackCornerRadius="4dp" app:indicatorSize="40dp"
app:trackThickness="3dp"
app:trackCornerRadius="2dp"
app:indicatorColor="?attr/colorPrimary" app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" /> app:trackColor="?attr/colorOutlineVariant" />
<TextView <TextView
android:id="@+id/tvOtpCountdown" android:id="@+id/tvOtpCountdown"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_gravity="center"
android:textAppearance="?attr/textAppearanceLabelSmall" android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" /> android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout>
</LinearLayout>
<!-- Current code + copy -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="monospace"
android:letterSpacing="0.12"
android:textAppearance="?attr/textAppearanceDisplaySmall"
android:textColor="?attr/colorPrimary"
android:textStyle="bold" />
<Button
android:id="@+id/btnCopyOtp"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Copy OTP"
android:tooltipText="Copy OTP"
app:icon="@drawable/ic_copy" />
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp" />
<!-- Next code + copy -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="Next"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvNextOtpCode"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="monospace"
android:letterSpacing="0.1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Button
android:id="@+id/btnCopyNextOtp"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Copy next OTP"
android:tooltipText="Copy next OTP"
app:icon="@drawable/ic_copy"
app:iconTint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
+11 -8
View File
@@ -13,10 +13,13 @@ Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed a
## TOTP Display ## TOTP Display
Each card shows: Each card shows:
- Bank logo and name - Bank logo, bank name and account holder name
- The current 6-digit TOTP code (large text) - A circular countdown ring with the seconds left in the current 30-second window; the ring and code turn red in the last 5 seconds
- A circular countdown ring showing time remaining in the current 30-second window - The current 6-digit TOTP code (large text) with a copy button
- The code refreshes automatically when the window expires — no user interaction needed - The next window's code (smaller, below a divider) with its own copy button — handy when the current code is about to expire
- The codes refresh automatically when the window expires — no user interaction needed
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
### Algorithm ### Algorithm
@@ -32,12 +35,12 @@ Standard RFC 6238 TOTP:
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`. One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
| Bank | Seed source | Card label | | Bank | Seed source | Card title / subtitle |
|---|---|---| |---|---|---|
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `"MIB · {fullName}"` | | MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{fullName}` |
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {fullName}"` | | BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `BML` / `{fullName}` |
If no full name has been cached the label falls back to plain `"MIB"` / `"BML"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it. If no full name has been cached the subtitle falls back to `"Authenticator"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
--- ---