Add support for otpauth:// and otpauth-migration:// QR scan during login
Auto Tag on Version Change / check-version (push) Failing after 11m54s
Auto Tag on Version Change / check-version (push) Failing after 11m54s
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -13,11 +14,13 @@ import android.os.Looper
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.util.OtpauthParser
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
@@ -34,6 +37,7 @@ import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.QrScannerActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class CredentialsFragment : Fragment() {
|
||||
@@ -60,6 +64,25 @@ class CredentialsFragment : Fragment() {
|
||||
private var bmlLoginId: String = ""
|
||||
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
||||
|
||||
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 entries = OtpauthParser.parse(raw)
|
||||
when {
|
||||
entries.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||
entries.size == 1 -> binding.etOtpSeed.setText(entries[0].secret)
|
||||
else -> {
|
||||
val labels = entries.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 -> binding.etOtpSeed.setText(entries[i].secret) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -75,7 +98,7 @@ class CredentialsFragment : Fragment() {
|
||||
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
||||
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
||||
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
||||
binding.tilOtpSeed.visibility = android.view.View.GONE
|
||||
binding.rowOtpSeed.visibility = android.view.View.GONE
|
||||
binding.etOtpSeed.isEnabled = false
|
||||
binding.etOtpSeed.isFocusable = false
|
||||
}
|
||||
@@ -83,6 +106,9 @@ class CredentialsFragment : Fragment() {
|
||||
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||
binding.btnScanOtpSeed.setOnClickListener {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
binding.cardOtp.setOnClickListener {
|
||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
|
||||
data class OtpEntry(val name: String, val issuer: String, val secret: String)
|
||||
|
||||
object OtpauthParser {
|
||||
|
||||
fun parse(raw: String): List<OtpEntry> = when {
|
||||
raw.startsWith("otpauth-migration://") -> parseMigration(raw)
|
||||
raw.startsWith("otpauth://") -> parseStandard(raw)?.let { listOf(it) } ?: emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun parseStandard(raw: String): OtpEntry? {
|
||||
val uri = Uri.parse(raw)
|
||||
val secret = uri.getQueryParameter("secret") ?: return null
|
||||
val issuer = uri.getQueryParameter("issuer") ?: ""
|
||||
val label = uri.path?.trimStart('/') ?: ""
|
||||
val name = if (':' in label) label.substringAfter(':').trim() else label
|
||||
return OtpEntry(name, issuer, secret.uppercase())
|
||||
}
|
||||
|
||||
private fun parseMigration(raw: String): List<OtpEntry> {
|
||||
val data = Uri.parse(raw).getQueryParameter("data") ?: return emptyList()
|
||||
val bytes = try { Base64.decode(data, Base64.DEFAULT) } catch (_: Exception) { return emptyList() }
|
||||
val reader = ProtobufReader(bytes)
|
||||
val entries = mutableListOf<OtpEntry>()
|
||||
while (reader.hasMore()) {
|
||||
val tag = reader.readVarint().toInt()
|
||||
val fieldNum = tag ushr 3
|
||||
val wireType = tag and 0x7
|
||||
if (fieldNum == 1 && wireType == 2) {
|
||||
parseOtpParameters(reader.readBytes())?.let { entries.add(it) }
|
||||
} else {
|
||||
reader.skip(wireType)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun parseOtpParameters(bytes: ByteArray): OtpEntry? {
|
||||
val reader = ProtobufReader(bytes)
|
||||
var secret: ByteArray? = null
|
||||
var name = ""
|
||||
var issuer = ""
|
||||
var type = 2 // default to TOTP
|
||||
while (reader.hasMore()) {
|
||||
val tag = reader.readVarint().toInt()
|
||||
val fieldNum = tag ushr 3
|
||||
val wireType = tag and 0x7
|
||||
when (fieldNum) {
|
||||
1 -> secret = reader.readBytes()
|
||||
2 -> name = String(reader.readBytes(), Charsets.UTF_8)
|
||||
3 -> issuer = String(reader.readBytes(), Charsets.UTF_8)
|
||||
6 -> type = reader.readVarint().toInt()
|
||||
else -> reader.skip(wireType)
|
||||
}
|
||||
}
|
||||
if (type == 1) return null // skip HOTP
|
||||
val secretBase32 = base32Encode(secret ?: return null)
|
||||
return OtpEntry(name, issuer, secretBase32)
|
||||
}
|
||||
|
||||
private fun base32Encode(bytes: ByteArray): String {
|
||||
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
val sb = StringBuilder()
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
for (b in bytes) {
|
||||
buffer = (buffer shl 8) or (b.toInt() and 0xFF)
|
||||
bitsLeft += 8
|
||||
while (bitsLeft >= 5) {
|
||||
bitsLeft -= 5
|
||||
sb.append(alphabet[(buffer ushr bitsLeft) and 0x1F])
|
||||
}
|
||||
}
|
||||
if (bitsLeft > 0) sb.append(alphabet[(buffer shl (5 - bitsLeft)) and 0x1F])
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private class ProtobufReader(private val bytes: ByteArray) {
|
||||
private var pos = 0
|
||||
|
||||
fun hasMore() = pos < bytes.size
|
||||
|
||||
fun readVarint(): Long {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (pos < bytes.size) {
|
||||
val b = bytes[pos++].toInt() and 0xFF
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun readBytes(): ByteArray {
|
||||
val len = readVarint().toInt()
|
||||
val data = bytes.copyOfRange(pos, pos + len)
|
||||
pos += len
|
||||
return data
|
||||
}
|
||||
|
||||
fun skip(wireType: Int) {
|
||||
when (wireType) {
|
||||
0 -> readVarint()
|
||||
1 -> pos += 8
|
||||
2 -> readBytes()
|
||||
5 -> pos += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,22 +73,42 @@
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilOtpSeed"
|
||||
<LinearLayout
|
||||
android:id="@+id/rowOtpSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/otp_seed"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:endIconMode="password_toggle"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etOtpSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilOtpSeed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:imeOptions="actionDone"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/otp_seed"
|
||||
app:endIconMode="password_toggle"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etOtpSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:imeOptions="actionDone"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanOtpSeed"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
app:icon="@drawable/ic_qr_scan"
|
||||
android:contentDescription="@string/scan_otp_qr"
|
||||
android:tooltipText="@string/scan_otp_qr" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilTotpCode"
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<string name="password">Password</string>
|
||||
<string name="otp_seed">OTP Seed (TOTP Secret)</string>
|
||||
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
|
||||
<string name="scan_otp_qr">Scan OTP QR</string>
|
||||
<string name="login">Login</string>
|
||||
|
||||
<!-- Lock screen -->
|
||||
|
||||
Reference in New Issue
Block a user