redegsigned OTP page and show next TOTP code
Auto Tag on Version Change / check-version (push) Successful in 3s
Auto Tag on Version Change / check-version (push) Successful in 3s
This commit is contained in:
@@ -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<OtpEntry>) :
|
||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -175,6 +175,32 @@
|
||||
|
||||
</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"
|
||||
|
||||
@@ -1,15 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
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>
|
||||
|
||||
@@ -2,55 +2,163 @@
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="2dp">
|
||||
app:cardCornerRadius="24dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
android:fontFamily="monospace"
|
||||
android:letterSpacing="0.15"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/otpProgress"
|
||||
<!-- Header: logo, bank + holder name, countdown ring -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:max="30"
|
||||
app:trackCornerRadius="4dp"
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackColor="?attr/colorSurfaceVariant" />
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCountdown"
|
||||
android:layout_width="wrap_content"
|
||||
<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
|
||||
android:id="@+id/tvOtpLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<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_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="false"
|
||||
android:max="30"
|
||||
app:indicatorSize="40dp"
|
||||
app:trackThickness="3dp"
|
||||
app:trackCornerRadius="2dp"
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackColor="?attr/colorOutlineVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCountdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Current code + copy -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
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>
|
||||
|
||||
|
||||
@@ -13,10 +13,13 @@ Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed a
|
||||
## TOTP Display
|
||||
|
||||
Each card shows:
|
||||
- Bank logo and name
|
||||
- The current 6-digit TOTP code (large text)
|
||||
- A circular countdown ring showing time remaining in the current 30-second window
|
||||
- The code refreshes automatically when the window expires — no user interaction needed
|
||||
- Bank logo, bank name and account holder name
|
||||
- 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
|
||||
- The current 6-digit TOTP code (large text) with a copy button
|
||||
- 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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
| Bank | Seed source | Card label |
|
||||
| Bank | Seed source | Card title / subtitle |
|
||||
|---|---|---|
|
||||
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `"MIB · {fullName}"` |
|
||||
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {fullName}"` |
|
||||
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user