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.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"
+33 -4
View File
@@ -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>
+141 -33
View File
@@ -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>