diff --git a/README.md b/README.md index 70d5d5e..bbbdfec 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,16 @@ A native Android client for Maldivian banking services. It is a pure client: req - Android 8.0+ (API 26) - Existing accounts with MIB, BML, or Fahipay -- Your TOTP seed (base32 secret from your authenticator app setup) for each bank +- Your TOTP seed (base32 secret from your authenticator app setup) for each bank. See [how to get your TOTP seed](docs/thijooree/faq/totpseed/README.md) ## Download APK [Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases) [Telegram Channel](https://t.me/s/thijooreeapks) +## FAQ + +Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md). + ## Privacy No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f098a76..8b872b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "sh.sar.basedbank" minSdk = 26 targetSdk = 36 - versionCode = 28 - versionName = "1.0.27" + versionCode = 32 + versionName = "1.0.31" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt new file mode 100644 index 0000000..c3d0b84 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/bml/BmlMerchantTxnClient.kt @@ -0,0 +1,82 @@ +package sh.sar.basedbank.api.bml + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject + +/** + * BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/`), + * e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it + * can go through the regular BML QR payment flow. + */ +class BmlMerchantTxnClient { + + private val client = newBmlApiClient() + + /** + * Returns the transaction's EMV QR payload (`vendorQrCode`). + * + * A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the + * page itself sends need no auth and return the full transaction: + * - on load, `activeBrowserId` (`_`); + * - on picking "BML" as the payment method, `provider: bml_mpos`. + * + * A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects + * one. Links already opened with BML chosen return it from the first. + */ + fun fetchQrPayload(transactionId: String): String { + val browserId = JSONObject() + .put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}") + patch(transactionId, browserId).vendorQrCode()?.let { return it } + // Whether the provider PATCH returns the QR itself or it is generated a moment later has + // not been observed, so re-read a few times before giving up. Re-reads use the load PATCH: + // each provider PATCH counts as another payment attempt. + var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML)) + repeat(3) { + txn.vendorQrCode()?.let { return it } + Thread.sleep(1000) + txn = patch(transactionId, browserId) + } + return txn.vendorQrCode() ?: throw Exception("Transaction has no QR") + } + + private fun patch(transactionId: String, body: JSONObject): JSONObject { + val request = Request.Builder() + .url("$API_BASE/transactions/$transactionId") + .patch(body.toString().toRequestBody("application/json".toMediaType())) + .header("Accept", "*/*") + .header("Origin", PAGE_ORIGIN) + .header("Referer", "$PAGE_ORIGIN/") + .build() + return client.newCall(request).execute().use { response -> + val text = response.body?.string().orEmpty() + if (!response.isSuccessful || !text.trimStart().startsWith("{")) + throw Exception("Transaction lookup failed (HTTP ${response.code})") + JSONObject(text) + } + } + + /** + * No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup + * already rejects a paid or expired QR with its own message. `isNull` first — `optString` + * turns a JSON null into the string "null". + */ + private fun JSONObject.vendorQrCode(): String? = + if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null } + + companion object { + private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv" + private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv" + private const val PROVIDER_BML = "bml_mpos" + private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$") + private val TXN_ID = Regex("^[0-9a-fA-F]{24}$") + + /** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */ + fun parseTransactionId(input: String): String? { + val s = input.trim() + val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1) + return id?.lowercase() + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index 4979253..42afed7 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) { } .build() + /** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */ + fun updateOtpSeed(otpSeed: String) { + if (storedOtpSeed != null) storedOtpSeed = otpSeed + } + // ─── Public entry point ─────────────────────────────────────────────────── /** diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 1b433e7..7d86da5 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -530,6 +530,10 @@ fun applyNavLabelVisibility() { private fun routeSharedQrText(text: String) { val store = CredentialStore(this) + sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let { + navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it)) + return + } val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text) if (bmlTarget != null) { navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber())) 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 fd233dd..95564ca 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 @@ -1,14 +1,25 @@ package sh.sar.basedbank.ui.home +import android.app.Activity import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color import android.os.Build import android.os.Bundle +import android.os.PersistableBundle +import android.text.Editable +import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.widget.PopupMenu import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager @@ -19,14 +30,22 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import com.google.android.material.color.MaterialColors +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel 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 +import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding +import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding import sh.sar.basedbank.databinding.FragmentOtpBinding import sh.sar.basedbank.databinding.ItemOtpCardBinding import sh.sar.basedbank.util.CredentialStore +import sh.sar.basedbank.util.OtpauthParser import sh.sar.basedbank.util.Totp class OtpFragment : Fragment() { @@ -34,7 +53,35 @@ class OtpFragment : Fragment() { private var _binding: FragmentOtpBinding? = null private val binding get() = _binding!! - private data class OtpEntry(val bank: String, val name: String?, val seed: String) + private data class OtpEntry( + val bank: String, val loginId: String, val account: String, val name: String?, val seed: String + ) + + private val entries = mutableListOf() + private var adapter: OtpAdapter? = null + + /** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */ + private var scanTarget: android.widget.EditText? = null + + 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 target = scanTarget ?: return@registerForActivityResult + val found = OtpauthParser.parse(raw) + when { + found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show() + found.size == 1 -> target.setText(found[0].secret) + else -> { + val labels = found.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 -> target.setText(found[i].secret) } + .show() + } + } + } private inner class OtpAdapter(private val entries: List) : RecyclerView.Adapter() { @@ -56,6 +103,8 @@ class OtpFragment : Fragment() { ) update(b, entry.seed) b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") } + // Long-press opens the seed menu (export / update) + b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true } b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") } b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") } } @@ -91,6 +140,170 @@ class OtpFragment : Fragment() { } } + private fun showSeedMenu(anchor: View, position: Int) { + if (position == RecyclerView.NO_POSITION) return + val popup = PopupMenu(anchor.context, anchor) + popup.menu.add(0, 1, 0, "Export seed") + popup.menu.add(0, 2, 1, "Update seed") + popup.setOnMenuItemClickListener { item -> + val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false + when (item.itemId) { + 1 -> showExportDialog(entry) + 2 -> showUpdateDialog(position) + } + true + } + popup.show() + } + + private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}" + + // ── Export ─────────────────────────────────────────────────────────────── + + private fun showExportDialog(entry: OtpEntry) { + val ctx = requireContext() + val d = DialogOtpExportSeedBinding.inflate(layoutInflater) + val uri = OtpauthParser.buildUri(entry.bank, entry.seed) + d.tvSeed.text = entry.seed.chunked(4).joinToString(" ") + renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) } + d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") } + MaterialAlertDialogBuilder(ctx) + .setTitle(entryTitle(entry)) + .setView(d.root) + .setPositiveButton("Done", null) + .show() + } + + private fun renderQr(content: String, size: Int): Bitmap? = try { + val hints = mapOf( + EncodeHintType.MARGIN to 0, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M + ) + val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints) + val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE } + Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888) + } catch (_: Exception) { null } + + /** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */ + private fun copySensitive(context: Context, text: String, message: String) { + val clip = ClipData.newPlainText("OTP seed", text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + clip.description.extras = PersistableBundle().apply { + putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) + } + } + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + + // ── Update ─────────────────────────────────────────────────────────────── + + private fun showUpdateDialog(position: Int) { + val entry = entries.getOrNull(position) ?: return + val ctx = requireContext() + val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater) + d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " + + "can't be recovered. If you might still need it, export it first." + + var newSeed: String? = null + // Same behaviour as the sign-in screen's preview card + val preview = d.cardOtp + preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") } + fun refreshPreview() { + val seed = newSeed + try { + if (seed == null) throw IllegalArgumentException() + preview.tvOtpCode.text = Totp.generate(seed) + preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1) + preview.otpTimer.max = 30 + preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt() + preview.root.visibility = View.VISIBLE + } catch (_: Exception) { + preview.root.visibility = View.INVISIBLE + } + } + + val dialog = MaterialAlertDialogBuilder(ctx) + .setTitle("Update seed · ${entry.bank}") + .setView(d.root) + .setPositiveButton("Replace", null) + .setNegativeButton("Cancel", null) + .create() + + fun validate() { + val raw = d.etNewSeed.text?.toString().orEmpty() + newSeed = OtpauthParser.resolveSecret(raw) + d.tilNewSeed.error = when { + raw.isBlank() -> null + newSeed == null -> "Not a valid TOTP seed" + newSeed == entry.seed -> "This is already the current seed" + else -> null + } + if (newSeed == entry.seed) newSeed = null + dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null + refreshPreview() + } + + d.etNewSeed.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) = validate() + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + }) + d.btnScanNewSeed.setOnClickListener { + scanTarget = d.etNewSeed + qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java)) + } + + val ticker = viewLifecycleOwner.lifecycleScope.launch { + while (isActive) { refreshPreview(); delay(1_000) } + } + dialog.setOnDismissListener { + ticker.cancel() + if (scanTarget === d.etNewSeed) scanTarget = null + } + dialog.setOnShowListener { + val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE) + replace.isEnabled = false + replace.setOnClickListener { + val seed = newSeed ?: return@setOnClickListener + confirmReplace(entry) { + saveSeed(position, seed) + dialog.dismiss() + } + } + } + dialog.show() + } + + private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete old seed?") + .setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " + + "recovered afterwards.") + .setPositiveButton("Replace") { _, _ -> onConfirm() } + .setNegativeButton("Cancel", null) + .show() + } + + private fun saveSeed(position: Int, seed: String) { + val entry = entries.getOrNull(position) ?: return + val store = CredentialStore(requireContext()) + when (entry.bank) { + "MIB" -> { + store.updateMibOtpSeed(entry.loginId, seed) + // The live flow keeps the seed in memory for silent re-login + (requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed) + } + "BML" -> store.updateBmlOtpSeed(entry.loginId, seed) + } + entries[position] = entry.copy(seed = seed) + adapter?.notifyItemChanged(position) + Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show() + } + private fun copyCode(context: Context, text: CharSequence, message: String) { val code = text.toString().replace(" ", "") if (code.isEmpty() || code.contains('-')) return @@ -115,16 +328,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("MIB", name, creds.otpSeed)) + tagged.add(CredentialStore.loginKey("mib", loginId) to + OtpEntry("MIB", loginId, creds.username, 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("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed)) + tagged.add(CredentialStore.loginKey("bml", loginId) to + OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed)) } - val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList() + entries.clear() + entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second }) - val adapter = OtpAdapter(entries) + val adapter = OtpAdapter(entries).also { this.adapter = it } binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE @@ -147,8 +363,7 @@ class OtpFragment : Fragment() { mobile = profile.mobile, enrolled = profile.enrolled )) - val seed = store.loadMibCredentials(loginId)?.otpSeed - val idx = entries.indexOfFirst { it.seed == seed } + val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId } if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true } } } @@ -168,8 +383,7 @@ class OtpFragment : Fragment() { idCard = info.idCard, birthdate = info.birthdate )) - val seed = store.loadBmlCredentials(loginId)?.otpSeed - val idx = entries.indexOfFirst { it.seed == seed } + val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId } if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true } } } @@ -192,6 +406,8 @@ class OtpFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() + adapter = null + scanTarget = null _binding = null } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt index 650ea5f..71eb0f4 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt @@ -7,12 +7,15 @@ import android.os.Build import android.os.Bundle import android.os.Environment import android.provider.MediaStore +import android.text.TextPaint +import android.text.TextUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.* import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.FileProvider +import androidx.core.content.res.ResourcesCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding @@ -65,7 +68,18 @@ class PayMvQrFragment : Fragment() { private data class QrTarget(val accountNumber: String, val name: String, val bank: String) private fun currentTarget(): QrTarget? = contactTarget - ?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) } + ?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) } + + /** Name printed on the card and put in the payload (tag 59). */ + private fun qrHolderName(account: BankAccount): String = when { + // Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile + account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" } + ?: CredentialStore(requireContext()) + .loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)) + ?.fullName?.takeIf { it.isNotBlank() } + ?: account.accountBriefName + else -> account.accountBriefName + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { contactTarget = arguments?.let { args -> @@ -146,11 +160,13 @@ class PayMvQrFragment : Fragment() { "FAHIPAY" -> "FAHIMVMV" else -> "MADVMVMV" } - val amountFormatted = binding.etAmount.text?.toString()?.trim() - ?.replace(",", "") + val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "") + val amountFormatted = amountRaw ?.toDoubleOrNull() ?.takeIf { it > 0 } ?.let { "%.2f".format(it) } + // BML shows the amount on the card as typed (no forced decimals) + val amountDisplay = amountRaw?.takeIf { amountFormatted != null } val ctx = requireContext() val account = selectedAccount @@ -165,17 +181,28 @@ class PayMvQrFragment : Fragment() { when { m.startsWith("+") -> m m.length == 7 -> "+960$m" + m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX else -> m } } } else null val purpose = binding.etReference.text?.toString()?.trim() - ?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default) + ?.takeIf { it.isNotBlank() } + + // The reference (62/05) is also printed vertically beside the QR, as each bank does + val reference = when (target.bank) { + // BML: base-32 account number followed by the amount as typed + "BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") + + (amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) } + "FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars + else -> generateReference(9) + } val bmp = withContext(Dispatchers.Default) { - val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose) - renderQrCard(ctx, target, payload, amountFormatted) + val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank) + if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference) + else renderQrCard(ctx, target, payload, reference) } if (_binding == null) return generatedBitmap = bmp @@ -194,14 +221,19 @@ class PayMvQrFragment : Fragment() { acquirer: String, amountStr: String?, mobile: String?, - purpose: String + purpose: String?, + ref: String, + bank: String ): String { fun tlv(tag: String, value: String): String { val len = value.length return tag + (if (len < 10) "0$len" else "$len") + value } val format = tlv("00", "01") - val poi = tlv("01", "11") + // Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount + // as "***" when they don't; its scanner may reject QRs that differ + val fahipay = bank == "FAHIPAY" + val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11") val sub00 = tlv("00", "mv.favara.mpqr") val sub01 = tlv("01", acquirer) val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app @@ -211,21 +243,28 @@ class PayMvQrFragment : Fragment() { val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10) val mcc = tlv("52", "0000") val currency = tlv("53", "462") - val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else "" + val amountTLV = when { + !amountStr.isNullOrBlank() -> tlv("54", amountStr) + fahipay -> tlv("54", "***") + else -> "" + } val country = tlv("58", "MV") - val name = tlv("59", accountName.take(25)) - val ref = generateReference() - val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose)) + val name = tlv("59", accountName.uppercase().take(25)) + // Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT + val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else "" + val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null + val purposeTLV = if (purposeText != null) tlv("08", purposeText) else "" + val addlData = tlv("62", tlv("05", ref) + purposeTLV) val timestamp = java.time.LocalDateTime.now() .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000")) val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp)) - val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304" + val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304" return prefix + crc16(prefix) } - private fun generateReference(): String { + private fun generateReference(length: Int): String { val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return (1..9).map { chars.random() }.joinToString("") + return (1..length).map { chars.random() }.joinToString("") } private fun crc16(data: String): String { @@ -242,88 +281,127 @@ class PayMvQrFragment : Fragment() { // ── QR card rendering ──────────────────────────────────────────────────── + /** + * Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in + * dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at + * [PX_PER_DP] pixels per dp. + */ private fun renderQrCard( ctx: Context, target: QrTarget, qrPayload: String, - amountStr: String? + qrId: String ): Bitmap { - val W = 900 - val H = 1080 - val outerCorner = 48f - val boxBlue = Color.parseColor("#2272B7") - val footerBlue = Color.parseColor("#1A5799") - val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f + val sw = SCREEN_WIDTH_DP + fun px(dp: Float) = dp * PX_PER_DP + val mmaBlue = Color.parseColor("#0E5CA4") - val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888) + val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side + val qrSize = sw * 0.5f + val railW = sw / 1.85f + + val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = px(14f) + typeface = Typeface.DEFAULT + textAlign = Paint.Align.CENTER + } + val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.WHITE + textSize = px(sw * 0.046f) + typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD + letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em + textAlign = Paint.Align.CENTER + } + // Android RN Text lines include font padding: line height = bottom - top + fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP + + // --- Vertical layout (dp, card-local) --- + val topCardTop = 2f + val brandTop = topCardTop + 32f // brandRow marginTop spacing[6] + val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide + val logoBoxH = logoBoxW * 0.1116751269035533f + val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide + val payMvBoxH = payMvBoxW * 0.17333333333333334f + val brandH = maxOf(logoBoxH, payMvBoxH) + val nameText = target.name.uppercase() + val hasName = nameText.isNotBlank() + val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f + val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3] + val nameH = if (hasName) lineHeight(namePaint) else 0f + val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f + val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5 + val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom + val footerLineH = lineHeight(footerPaint) + val cardH = topCardBottom + 12f + footerLineH + 12f + 2f + + val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888) val canvas = Canvas(bm) val paint = Paint(Paint.ANTI_ALIAS_FLAG) - // Clip to outer rounded card shape - val outerPath = Path() - outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW) - canvas.clipPath(outerPath) - canvas.drawColor(Color.WHITE) - - // --- Bank logo top-left --- - val logoRes = when (target.bank) { - "BML" -> R.drawable.bml_logo_vector - "MIB" -> R.drawable.mib_faisanet_logo - else -> R.drawable.fahipay_logo_long + // captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card + val outerPath = Path().apply { + addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW) } + canvas.clipPath(outerPath) + canvas.drawColor(mmaBlue) + + // topCard: white, 2dp inset, top corners 18 + paint.color = Color.WHITE + val r = px(18f) + canvas.drawPath(Path().apply { + addRoundRect( + RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)), + floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW + ) + }, paint) + + // --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins --- + val rowL = 2f + 40f + val rowR = cardW - 2f - 40f + val rowCenterY = brandTop + brandH / 2 + val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo AppCompatResources.getDrawable(ctx, logoRes)?.let { d -> val nW = d.intrinsicWidth.coerceAtLeast(1) val nH = d.intrinsicHeight.coerceAtLeast(1) - val maxW = 180f; val maxH = 76f - val scale = minOf(maxW / nW, maxH / nH) + // resizeMode "contain" inside the logo box, left-aligned in the row + val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH) val lW = (nW * scale).toInt() val lH = (nH * scale).toInt() - val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10) - d.setBounds(24, lTop, 24 + lW, lTop + lH) + val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt() + val lTop = (px(rowCenterY) - lH / 2f).toInt() + d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH) d.draw(canvas) } - - // --- "PayMV QR" top-right --- - paint.color = Color.parseColor("#1A1A2E") - paint.textSize = 36f - paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + // BML draws the paymv-logo image (fills its box exactly), nudged down 1dp + paint.color = mmaBlue + paint.typeface = footerPaint.typeface paint.textAlign = Paint.Align.RIGHT - canvas.drawText("PayMV QR", W - 28f, 66f, paint) + paint.textSize = px(payMvBoxH) + paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR") + val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) } + canvas.drawText( + "PayMV QR", px(rowR), + px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint + ) - // --- Blue rounded box --- - paint.color = boxBlue - paint.textAlign = Paint.Align.LEFT - canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint) + // --- qrCard: mmaBlue, radius 16, 40dp side margins --- + val qrCardL = 2f + 40f + val qrCardR = cardW - 2f - 40f + paint.color = mmaBlue + canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint) - // Account name (white, bold, uppercase, auto-scaled to fit) - paint.color = Color.WHITE - paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - paint.textAlign = Paint.Align.CENTER - val nameText = target.name.uppercase() - paint.textSize = 36f - val maxNameW = boxR - boxL - 48f - if (paint.measureText(nameText) > maxNameW) { - paint.textSize = 36f * maxNameW / paint.measureText(nameText) - } - val nameBaseline = boxT + 68f - canvas.drawText(nameText, W / 2f, nameBaseline, paint) - - // Optional amount below name - val qrTopY: Float - if (!amountStr.isNullOrBlank()) { - paint.textSize = 28f - paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) - val amtBaseline = nameBaseline + 42f - canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint) - qrTopY = amtBaseline + 20f - } else { - qrTopY = nameBaseline + 26f + if (hasName) { + val maxNameW = px(qrCardR - qrCardL) + if (namePaint.measureText(nameText) > maxNameW) { + namePaint.textSize *= maxNameW / namePaint.measureText(nameText) + } + canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint) } - // QR code — white modules on the same blue as the box background - val availH = boxB - qrTopY - 24f - val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200) - val qrLeft = ((W - qrPx) / 2).toFloat() + // QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults) + val qrPx = px(qrSize).toInt() + val qrLeft = px(cardW / 2) - qrPx / 2f try { val hints = mapOf( EncodeHintType.MARGIN to 0, @@ -333,23 +411,150 @@ class PayMvQrFragment : Fragment() { val pixels = IntArray(qrPx * qrPx) for (y in 0 until qrPx) { for (x in 0 until qrPx) { - pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue + pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue } } val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888) - canvas.drawBitmap(qrBm, qrLeft, qrTopY, null) + canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null) qrBm.recycle() } catch (_: Exception) { /* skip if encoding fails */ } - // --- Dark blue footer --- - paint.color = footerBlue - paint.textAlign = Paint.Align.LEFT - canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint) + // qrIdRail: the reference, rotated -90°, beside the QR's right edge + if (qrId.isNotEmpty()) { + val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + alpha = (255 * 0.8f).toInt() + textSize = px(10f) + typeface = Typeface.DEFAULT + textAlign = Paint.Align.CENTER + } + val qrRight = cardW / 2 + qrSize / 2 + val cx = px(qrRight + railW / 1.37f - railW / 2) + val cy = px(qrTop + (qrSize + railW / 1.5f) / 2) + val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString() + canvas.save() + canvas.rotate(-90f, cx, cy) + val fm = idPaint.fontMetrics + canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint) + canvas.restore() + } + + // --- footer: SofiaPro-Bold, letterSpacing 1.2 --- + canvas.drawText( + "MALDIVES NATIONAL QR", px(cardW / 2), + px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint + ) + + return bm + } + + /** + * Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured + * in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto), + * except the vertical reference, which is Montserrat as on Fahipay's. + */ + private fun renderFahipayQrCard( + ctx: Context, + target: QrTarget, + qrPayload: String, + reference: String + ): Bitmap { + val w = 1240f + val h = 1322f + val blue = Color.parseColor("#005DA3") + val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD + val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT + + val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888) + val canvas = Canvas(bm) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + // Blue card with a 6px border around the white area; footer is the blue below it + canvas.clipPath(Path().apply { + addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW) + }) + canvas.drawColor(blue) paint.color = Color.WHITE - paint.textSize = 32f - paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) - paint.textAlign = Paint.Align.CENTER - canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint) + canvas.drawPath(Path().apply { + addRoundRect( + RectF(6f, 6f, w - 6f, 1174f), + floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW + ) + }, paint) + + // Square app icon, then the "FahiPay" wordmark, in one row + AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d -> + d.setBounds(94, 94, 163, 163) + d.draw(canvas) + } + AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d -> + val lH = 48 + val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt() + d.setBounds(178, 104, 178 + lW, 104 + lH) + d.draw(canvas) + } + + // Sized so capitals match the reference's cap heights; positions below are cap tops + fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + typeface = tf + letterSpacing = spacingEm + textAlign = align + textSize = 100f + val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1) + textSize = 100f * capH / h + } + fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height() + + // "PayMV QR" top-right + val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue } + canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint) + + // Blue QR panel + paint.color = blue + canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint) + + // Account name + val nameText = target.name.uppercase() + if (nameText.isNotBlank()) { + val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE } + val maxNameW = 1074f - 166f - 80f + if (namePaint.measureText(nameText) > maxNameW) { + namePaint.textSize *= maxNameW / namePaint.measureText(nameText) + } + canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint) + } + + // QR — white modules on blue, no quiet zone + val qrPx = 562 + try { + val hints = mapOf( + EncodeHintType.MARGIN to 0, + EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M + ) + val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints) + val pixels = IntArray(qrPx * qrPx) + for (y in 0 until qrPx) { + for (x in 0 until qrPx) { + pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else blue + } + } + val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888) + canvas.drawBitmap(qrBm, 338f, 417f, null) + qrBm.recycle() + } catch (_: Exception) { /* skip if encoding fails */ } + + // Reference, blue, reading bottom-to-top in the white margin right of the panel, + // starting level with y=1087 and with its baseline at x=1171 + val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue } + canvas.save() + canvas.rotate(-90f, 1171f, 1087f) + canvas.drawText(reference, 1171f, 1087f, refPaint) + canvas.restore() + + // Footer + val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE } + canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint) return bm } @@ -437,6 +642,10 @@ class PayMvQrFragment : Fragment() { private const val ARG_ACCOUNT_NAME = "account_name" private const val ARG_BANK = "bank" + /** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */ + private const val SCREEN_WIDTH_DP = 560f + private const val PX_PER_DP = 2f + /** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */ fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply { arguments = Bundle().apply { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt index fa70889..1fb9528 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsAppearanceFragment.kt @@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() { val isDark = prefs.getString("theme", "system") == "dark" updatePitchBlackState(isDark) + // Receipts + binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false) + binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked -> + prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply() + } + // Accent color val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE) binding.accentToggle.check(when (savedPreset) { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 8ecd01f..6d585a8 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -39,6 +39,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R +import sh.sar.basedbank.api.bml.BmlMerchantTxnClient import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankContact import sh.sar.basedbank.api.mib.MibIpsAccountInfo @@ -80,6 +81,8 @@ class TransferFragment : Fragment() { private var resolvedAccountNumber = "" private var resolvedRecipientName = "" private var resolvedBankName = "" + /** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */ + private var loadedToPhoto: Bitmap? = null private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown private var resolvedToOwnAccount: BankAccount? = null @@ -175,13 +178,17 @@ class TransferFragment : Fragment() { if (result.resultCode != Activity.RESULT_OK) return val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return + // BML Merchant Services payment link — resolve it to the QR its page would show + BmlMerchantTxnClient.parseTransactionId(raw)?.let { + binding.etTo.setText(it) + lookupBmlMerchantTransaction(it) + return + } + // BML card/gateway/POS QR — hand off to dedicated payment screen val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw) if (bmlTarget != null) { - val fromCard = selectedAccount?.takeIf { - it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" - } - (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber)) + openBmlQr(bmlTarget) return } @@ -245,6 +252,7 @@ class TransferFragment : Fragment() { private const val ARG_REMARKS_PREFILL = "remarks_prefill" private const val ARG_BML_QR_URL = "bml_qr_url" private const val ARG_AUTO_SCAN = "auto_scan" + private const val ARG_BML_TXN_ID = "bml_txn_id" private const val ARG_RETURN_ON_SUCCESS = "return_on_success" private const val ARG_AUTO_CONFIRM = "auto_confirm" @@ -263,6 +271,11 @@ class TransferFragment : Fragment() { } } + /** Opens on a BML Merchant Services transaction ID, which is resolved to its QR on load. */ + fun newInstanceFromBmlTxn(transactionId: String) = TransferFragment().apply { + arguments = Bundle().apply { putString(ARG_BML_TXN_ID, transactionId) } + } + fun newInstanceFrom(account: BankAccount) = TransferFragment().apply { arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) } } @@ -422,6 +435,11 @@ class TransferFragment : Fragment() { } arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) } + arguments?.getString(ARG_BML_TXN_ID)?.let { + // Shown in the To field so a failed lookup leaves the ID there to retry or correct. + binding.etTo.setText(it) + lookupBmlMerchantTransaction(it) + } if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { launchQrScanner() @@ -806,7 +824,40 @@ class TransferFragment : Fragment() { setupContactDropdown() } + /** Reopens the Transfer screen in BML merchant-QR mode, keeping a selected BML card as the source. */ + private fun openBmlQr(bmlTarget: String) { + val fromCard = selectedAccount?.takeIf { + it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" + } + (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber)) + } + + /** + * A BML Merchant Services transaction ID (or its payment link) typed into the To field: fetch + * the QR the payment page would show and pay it like a scanned one. + */ + private fun lookupBmlMerchantTransaction(transactionId: String) { + startLookupLoading() + viewLifecycleOwner.lifecycleScope.launch { + val target = withContext(Dispatchers.IO) { + runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) } + .getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) } + } + if (_binding == null) return@launch + stopLookupLoading() + if (target == null) { + binding.tilTo.error = getString(R.string.transfer_bml_txn_lookup_failed) + return@launch + } + openBmlQr(target) + } + } + private fun searchTo() { + BmlMerchantTxnClient.parseTransactionId(binding.etTo.text?.toString().orEmpty())?.let { + lookupBmlMerchantTransaction(it) + return + } // M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails) if (selectedAccount?.bank == "MFAISA") { mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty()) @@ -1211,6 +1262,8 @@ class TransferFragment : Fragment() { val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber val bankNameCapture = resolvedBankName val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap + // The MIB receipt only takes a real photo; it draws its own initials placeholder otherwise + val capturedToPhoto = capturedToAvatar?.takeIf { it === loadedToPhoto } val destCurrency = resolvedDestCurrency.ifBlank { allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber } @@ -1248,7 +1301,7 @@ class TransferFragment : Fragment() { val activity = requireActivity() as HomeActivity activity.triggerRefresh() dialog.dismiss() - showReceipt(receipt, capturedToAvatar) + showReceipt(receipt, capturedToPhoto) } else if (!ok) { dialog.dismiss() if (msg == "CONNECTIVITY") { @@ -1624,6 +1677,7 @@ class TransferFragment : Fragment() { if (_binding != null) { binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP binding.ivToPhoto.setImageBitmap(bitmap) + loadedToPhoto = bitmap } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt index f497a3c..73620f0 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptData.kt @@ -14,6 +14,8 @@ data class TransferReceiptData( // MIB receipt fields val mibReferenceNo: String = "", val mibTransactionDate: String = "", + val mibFromProfileName: String = "", + val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer" // BML receipt fields val bmlFromName: String = "", val bmlReference: String = "", diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt index a278114..13f0a48 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferReceiptFragment.kt @@ -24,6 +24,7 @@ import android.widget.Toast import androidx.core.content.FileProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton @@ -33,6 +34,8 @@ import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.mib.MibContactsClient +import sh.sar.basedbank.databinding.DialogReceiptFullscreenBmlBinding +import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding import sh.sar.basedbank.databinding.FragmentReceiptMibBinding @@ -61,6 +64,8 @@ class TransferReceiptFragment : Fragment() { private const val ARG_REMARKS = "remarks" private const val ARG_MIB_REF = "mib_ref" private const val ARG_MIB_DATE = "mib_date" + private const val ARG_MIB_FROM_PROFILE = "mib_from_profile" + private const val ARG_MIB_TXN_TYPE = "mib_txn_type" private const val ARG_BML_FROM_NAME = "bml_from_name" private const val ARG_BML_REFERENCE = "bml_reference" private const val ARG_BML_TIMESTAMP = "bml_timestamp" @@ -89,6 +94,8 @@ class TransferReceiptFragment : Fragment() { putString(ARG_REMARKS, data.remarks) putString(ARG_MIB_REF, data.mibReferenceNo) putString(ARG_MIB_DATE, data.mibTransactionDate) + putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName) + putString(ARG_MIB_TXN_TYPE, data.mibTransactionType) putString(ARG_BML_FROM_NAME, data.bmlFromName) putString(ARG_BML_REFERENCE, data.bmlReference) putString(ARG_BML_TIMESTAMP, data.bmlTimestamp) @@ -161,6 +168,15 @@ class TransferReceiptFragment : Fragment() { view.findViewById(R.id.btnSave).setOnClickListener { saveReceipt() } + + val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) + .getBoolean("always_fullscreen_receipt", false) + if (alwaysFullScreen) { + // The normal page is skipped: keep it laid out (share/save capture its card) but hidden, + // and leave the receipt entirely when the full-screen view is closed + view.alpha = 0f + view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) } + } } // ── Data binding ────────────────────────────────────────────────────────── @@ -168,43 +184,64 @@ class TransferReceiptFragment : Fragment() { private fun bindMib(binding: FragmentReceiptMibBinding) { val args = requireArguments() val fromLabel = args.getString(ARG_FROM_LABEL, "") - val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E") val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH) val toLabel = args.getString(ARG_TO_LABEL, "") val currency = args.getString(ARG_CURRENCY, "MVR") val amount = args.getString(ARG_AMOUNT, "") // From avatar: initials first, then load profile image if hash available - binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor)) + binding.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel)) binding.tvFromLabel.text = fromLabel if (fromProfileHash != null) { - loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) } + loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) } } // To avatar: use already-rendered bitmap from TransferFragment if available val toAvatar = pendingToAvatarBitmap if (toAvatar != null) { - binding.ivToAvatar.setImageBitmap(toAvatar) + binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar)) } else { - binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B")) + binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel)) } binding.tvToLabel.text = toLabel binding.tvAmount.text = "$currency $amount" + + val toBank = args.getString(ARG_TO_BANK, "") + val rawDate = args.getString(ARG_MIB_DATE, "") binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "") - binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "") - binding.tvToBank.text = args.getString(ARG_TO_BANK, "") - binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "") - binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "") + binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel } + binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, "")) + .filter { it.isNotBlank() }.joinToString("\n") + binding.tvToBank.text = toBank + // Receipts saved before the type was recorded fall back to a guess from the bank + binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank { + if (toBank == "MIB") "MIB Transfer" else "Quick Transfer" + } + binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm") + binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy") binding.tvPurpose.text = args.getString(ARG_REMARKS, "") + .takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A" copyOnLongClick( - binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, - binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank, - binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose + binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus, + binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank, + binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose ) } + /** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */ + private fun formatMibDate(raw: String, pattern: String): String { + if (raw.isBlank()) return "" + val out = DateTimeFormatter.ofPattern(pattern, Locale.US) + for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) { + try { + return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out) + } catch (_: Exception) { } + } + return raw + } + private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { val app = requireActivity().application as BasedBankApp val sess = app.anyMibSession() ?: return @@ -375,8 +412,13 @@ class TransferReceiptFragment : Fragment() { * applied to fit small viewports and doesn't pick up overlapping siblings. */ private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) { - val view = _receiptCard ?: run { callback(null); return } - if (view.width == 0 || view.height == 0) { callback(null); return } + val shown = _receiptCard ?: run { callback(null); return } + if (shown.width == 0 || shown.height == 0) { callback(null); return } + + // BML: the preview follows the app theme, but shared/saved images are always light + val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") { + inflateLightBmlCard(shown.width) + } else shown val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) val canvas = Canvas(bitmap) @@ -385,6 +427,27 @@ class TransferReceiptFragment : Fragment() { callback(bitmap) } + /** Inflates and lays out an offscreen BML receipt card with light-mode resources. */ + private fun inflateLightBmlCard(widthPx: Int): View { + val config = android.content.res.Configuration(resources.configuration).apply { + uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or + android.content.res.Configuration.UI_MODE_NIGHT_NO + } + val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply { + applyOverrideConfiguration(config) + } + val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx)) + bindBml(binding) + val card = binding.receiptCard + (card.parent as? ViewGroup)?.removeView(card) + card.measure( + View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + card.layout(0, 0, card.measuredWidth, card.measuredHeight) + return card + } + private fun formatBmlTimestamp(raw: String): String { if (raw.isBlank()) return "" return try { @@ -394,26 +457,46 @@ class TransferReceiptFragment : Fragment() { } } - private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap { + /** Center-crops [src] to a square and masks it to a circle. */ + private fun circleCrop(src: Bitmap): Bitmap { + val size = minOf(src.width, src.height) + val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply { + setLocalMatrix(android.graphics.Matrix().apply { + setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f) + }) + } + } + Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint) + return out + } + + /** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */ + private fun makeMibInitialsBitmap(name: String): Bitmap { val sizePx = (resources.displayMetrics.density * 52).toInt() - val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY } val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) val canvas = Canvas(bm) val paint = Paint(Paint.ANTI_ALIAS_FLAG) - paint.color = bgColor + paint.color = Color.parseColor("#C6E1FD") canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint) - paint.color = Color.WHITE - paint.textSize = sizePx * 0.42f + paint.color = Color.parseColor("#1168F3") + paint.textSize = sizePx * 0.36f paint.textAlign = Paint.Align.CENTER - val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?" + paint.typeface = android.graphics.Typeface.DEFAULT_BOLD + val initials = name.split(Regex("\\s+")) + .mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() } + .take(2).joinToString("").ifEmpty { "?" } val metrics = paint.fontMetrics - canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) + canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) return bm } - private fun showFullScreenReceipt() { + private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) { val ctx = requireContext() val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB" + if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return } + if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return } val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen) val scrollView = android.widget.ScrollView(ctx).apply { @@ -455,6 +538,7 @@ class TransferReceiptFragment : Fragment() { android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO insetsCtrl.isAppearanceLightStatusBars = isLight + if (closePageOnDismiss) closeReceiptPage() } dialog.show() dialog.window?.let { win -> @@ -466,6 +550,121 @@ class TransferReceiptFragment : Fragment() { } } + /** + * BML full-screen receipt: status bar stays visible, top bar with back button, + * edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card. + * Follows the app theme (light/dark). + */ + private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) { + val ctx = requireContext() + val dialog = Dialog(ctx, R.style.Theme_BasedBank) + val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater) + + val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard + (card.parent as? ViewGroup)?.removeView(card) + page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + )) + + page.btnBack.setOnClickListener { dialog.dismiss() } + if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() } + page.btnSaveFull.setOnClickListener { saveReceipt() } + page.btnShareFull.setOnClickListener { shareReceipt() } + + val topBasePadding = page.topBar.paddingTop + val bottomBasePadding = page.bottomBar.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + page.topBar.updatePadding(top = topBasePadding + bars.top) + page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom) + page.root.updatePadding(left = bars.left, right = bars.right) + insets + } + + dialog.setContentView(page.root) + dialog.window?.let { win -> + win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false) + @Suppress("DEPRECATION") + win.statusBarColor = Color.TRANSPARENT + @Suppress("DEPRECATION") + win.navigationBarColor = Color.TRANSPARENT + val isLight = (resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_NO + androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply { + isAppearanceLightStatusBars = isLight + isAppearanceLightNavigationBars = isLight + } + } + dialog.show() + } + + /** + * MIB full-screen receipt: edge-to-edge card whose green header runs under the + * (visible) status bar, a floating close button top-right just below the status bar, + * and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme. + */ + private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) { + val ctx = requireContext() + val dialog = Dialog(ctx, R.style.Theme_BasedBank) + val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater) + + val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) } + val card = cardBinding.receiptCard + (card.parent as? ViewGroup)?.removeView(card) + page.cardHolder.addView(card, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + )) + + page.btnClose.setOnClickListener { dialog.dismiss() } + if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() } + page.btnShareFull.setOnClickListener { shareReceipt() } + page.btnSaveFull.setOnClickListener { saveReceipt() } + + val header = cardBinding.receiptHeader + val headerBaseHeight = header.layoutParams.height + val headerBasePadding = header.paddingTop + val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin + val bottomBasePadding = page.bottomBar.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets -> + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + // Grow the green header under the status bar, keeping its content below it + header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top } + header.updatePadding(top = headerBasePadding + bars.top) + page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams) + .apply { topMargin = closeBaseMargin + bars.top } + page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom) + page.root.updatePadding(left = bars.left, right = bars.right) + insets + } + + dialog.setContentView(page.root) + dialog.window?.let { win -> + win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false) + @Suppress("DEPRECATION") + win.statusBarColor = Color.TRANSPARENT + @Suppress("DEPRECATION") + win.navigationBarColor = Color.TRANSPARENT + val isLight = (resources.configuration.uiMode and + android.content.res.Configuration.UI_MODE_NIGHT_MASK) == + android.content.res.Configuration.UI_MODE_NIGHT_NO + androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply { + // Status bar sits over the green header, so always use light icons + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = isLight + } + } + dialog.show() + } + + /** Pops this receipt off the back stack, returning to whatever screen opened it. */ + private fun closeReceiptPage() { + if (!isAdded || parentFragmentManager.isStateSaved) return + parentFragmentManager.popBackStack() + } + private fun copyOnLongClick(vararg views: android.widget.TextView) { for (tv in views) { tv.setOnLongClickListener { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt index a5c6cb4..8c670aa 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MibTransferHandler.kt @@ -129,6 +129,15 @@ class MibTransferHandler( else -> bankName.ifBlank { "LOCAL" } } } + val isOwnAccount = isDestMib && app.mibAccounts.any { + it.accountNumber == destAccount && it.loginTag == src.loginTag && + (src.profileId.isBlank() || it.profileId == src.profileId) + } + val transactionType = when { + isOwnAccount -> "Own Transfer" + isDestMib -> "MIB Transfer" + else -> "Quick Transfer" + } return try { // Switch to the profile that owns the source account if (src.profileId.isNotBlank()) { @@ -160,7 +169,9 @@ class MibTransferHandler( toBank = toBank, remarks = remarks, mibReferenceNo = result.trxId, - mibTransactionDate = result.date + mibTransactionDate = result.date, + mibFromProfileName = src.profileName, + mibTransactionType = transactionType ) Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt) } else { 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 886cabd..274f226 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 @@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() { qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) } - binding.cardOtp.setOnClickListener { - val code = binding.tvOtpCode.text.toString().replace(" ", "") + binding.cardOtp.root.setOnClickListener { + val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "") if (code.isNotEmpty()) { val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) @@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() { val otpSeedRaw = binding.etOtpSeed.text.toString().trim() val seed = resolveOtpSeed(otpSeedRaw) if (seed.isEmpty()) { - binding.cardOtp.visibility = View.INVISIBLE + binding.cardOtp.root.visibility = View.INVISIBLE return } val password = binding.etPassword.text.toString() if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) { - binding.cardOtp.visibility = View.INVISIBLE + binding.cardOtp.root.visibility = View.INVISIBLE return } try { @@ -211,13 +211,13 @@ class CredentialsFragment : Fragment() { val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt() 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 + binding.cardOtp.tvOtpCode.text = otp + binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1) + binding.cardOtp.otpTimer.max = 30 + binding.cardOtp.otpTimer.progress = remaining + binding.cardOtp.root.visibility = View.VISIBLE } catch (e: Exception) { - binding.cardOtp.visibility = View.INVISIBLE + binding.cardOtp.root.visibility = View.INVISIBLE } } diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt index e3574d7..5384691 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -100,6 +100,11 @@ class CredentialStore(context: Context) { .apply() } + /** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */ + fun updateMibOtpSeed(loginId: String, otpSeed: String) { + prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply() + } + fun loadMibCredentials(loginId: String): MibCredentials? { val key = getOrCreateKey() val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null @@ -230,6 +235,11 @@ class CredentialStore(context: Context) { .apply() } + /** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */ + fun updateBmlOtpSeed(loginId: String, otpSeed: String) { + prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply() + } + fun loadBmlCredentials(loginId: String): BmlCredentials? { val key = getOrCreateKey() val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null diff --git a/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt b/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt index 6a2484c..a1574fd 100644 --- a/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/OtpauthParser.kt @@ -13,6 +13,27 @@ object OtpauthParser { else -> emptyList() } + /** + * Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw + * secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed. + */ + fun resolveSecret(input: String): String? { + val raw = input.trim() + val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw + val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase() + if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null + // Too short to be a real seed, e.g. a pasted 6-digit OTP code + if (clean.length < 8) return null + return clean + } + + /** + * Build the shortest otpauth://totp link authenticator apps accept: just a label and the + * secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out. + */ + fun buildUri(label: String, secret: String): String = + "otpauth://totp/" + Uri.encode(label) + "?secret=$secret" + private fun parseStandard(raw: String): OtpEntry? { val uri = Uri.parse(raw) val secret = uri.getQueryParameter("secret") ?: return null diff --git a/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt b/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt index fbcc986..e1bac74 100644 --- a/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt @@ -40,6 +40,8 @@ object ReceiptStore { remarks = o.optString("remarks"), mibReferenceNo = o.optString("mibReferenceNo"), mibTransactionDate = o.optString("mibTransactionDate"), + mibFromProfileName = o.optString("mibFromProfileName"), + mibTransactionType = o.optString("mibTransactionType"), bmlFromName = o.optString("bmlFromName"), bmlReference = o.optString("bmlReference"), bmlTimestamp = o.optString("bmlTimestamp"), @@ -76,6 +78,8 @@ object ReceiptStore { put("remarks", d.remarks) put("mibReferenceNo", d.mibReferenceNo) put("mibTransactionDate", d.mibTransactionDate) + put("mibFromProfileName", d.mibFromProfileName) + put("mibTransactionType", d.mibTransactionType) put("bmlFromName", d.bmlFromName) put("bmlReference", d.bmlReference) put("bmlTimestamp", d.bmlTimestamp) diff --git a/app/src/main/res/drawable-night/bottom_receipt_wave.xml b/app/src/main/res/drawable-night/bottom_receipt_wave.xml new file mode 100644 index 0000000..b4982e5 --- /dev/null +++ b/app/src/main/res/drawable-night/bottom_receipt_wave.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/drawable-nodpi/bml_logo_paymv.png b/app/src/main/res/drawable-nodpi/bml_logo_paymv.png new file mode 100644 index 0000000..a9abad5 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/bml_logo_paymv.png differ diff --git a/app/src/main/res/drawable/bg_mib_receipt_header.xml b/app/src/main/res/drawable/bg_mib_receipt_header.xml deleted file mode 100644 index 261ae7b..0000000 --- a/app/src/main/res/drawable/bg_mib_receipt_header.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/bml_icon.jpg b/app/src/main/res/drawable/bml_icon.jpg deleted file mode 100644 index e35d50d..0000000 Binary files a/app/src/main/res/drawable/bml_icon.jpg and /dev/null differ diff --git a/app/src/main/res/drawable/ic_chevron_back.xml b/app/src/main/res/drawable/ic_chevron_back.xml new file mode 100644 index 0000000..0752c5a --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron_back.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_mib_receipt_close.xml b/app/src/main/res/drawable/ic_mib_receipt_close.xml new file mode 100644 index 0000000..05c8dce --- /dev/null +++ b/app/src/main/res/drawable/ic_mib_receipt_close.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/mib_logo_full.xml b/app/src/main/res/drawable/mib_logo_full.xml new file mode 100644 index 0000000..a5b0eb7 --- /dev/null +++ b/app/src/main/res/drawable/mib_logo_full.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/mib_receipt_header_bg.xml b/app/src/main/res/drawable/mib_receipt_header_bg.xml new file mode 100644 index 0000000..8980193 --- /dev/null +++ b/app/src/main/res/drawable/mib_receipt_header_bg.xml @@ -0,0 +1,10 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/mib_receipt_texture.png b/app/src/main/res/drawable/mib_receipt_texture.png new file mode 100644 index 0000000..522e1c0 Binary files /dev/null and b/app/src/main/res/drawable/mib_receipt_texture.png differ diff --git a/app/src/main/res/drawable/trx_success_bg.png b/app/src/main/res/drawable/trx_success_bg.png deleted file mode 100644 index 35db358..0000000 Binary files a/app/src/main/res/drawable/trx_success_bg.png and /dev/null differ diff --git a/app/src/main/res/font/montserrat_regular.ttf b/app/src/main/res/font/montserrat_regular.ttf new file mode 100644 index 0000000..2a2b2aa Binary files /dev/null and b/app/src/main/res/font/montserrat_regular.ttf differ diff --git a/app/src/main/res/font/sofia_pro_bold.ttf b/app/src/main/res/font/sofia_pro_bold.ttf new file mode 100644 index 0000000..cd156bf Binary files /dev/null and b/app/src/main/res/font/sofia_pro_bold.ttf differ diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index e60b7cc..45a63ce 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -48,6 +48,7 @@ android:id="@+id/languageToggle" android:layout_width="match_parent" android:layout_height="wrap_content" + android:baselineAligned="false" app:singleSelection="true" app:selectionRequired="true"> diff --git a/app/src/main/res/layout/dialog_otp_export_seed.xml b/app/src/main/res/layout/dialog_otp_export_seed.xml new file mode 100644 index 0000000..5983c6f --- /dev/null +++ b/app/src/main/res/layout/dialog_otp_export_seed.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_otp_update_seed.xml b/app/src/main/res/layout/dialog_otp_update_seed.xml new file mode 100644 index 0000000..a93f675 --- /dev/null +++ b/app/src/main/res/layout/dialog_otp_update_seed.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_receipt_fullscreen_bml.xml b/app/src/main/res/layout/dialog_receipt_fullscreen_bml.xml new file mode 100644 index 0000000..ee593a2 --- /dev/null +++ b/app/src/main/res/layout/dialog_receipt_fullscreen_bml.xml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_receipt_fullscreen_mib.xml b/app/src/main/res/layout/dialog_receipt_fullscreen_mib.xml new file mode 100644 index 0000000..dc0e6b3 --- /dev/null +++ b/app/src/main/res/layout/dialog_receipt_fullscreen_mib.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_credentials.xml b/app/src/main/res/layout/fragment_credentials.xml index feeed55..e60c6c4 100644 --- a/app/src/main/res/layout/fragment_credentials.xml +++ b/app/src/main/res/layout/fragment_credentials.xml @@ -130,89 +130,13 @@ android:maxLength="6" /> - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_marginBottom="8dp" /> + android:background="@color/bml_receipt_bg"> @@ -47,7 +47,7 @@ android:layout_marginBottom="20dp" android:id="@+id/tvMessage" android:textSize="14sp" - android:textColor="#2D2D2D" + android:textColor="@color/bml_receipt_message" android:fontFamily="@font/nunito_sans" android:gravity="center" /> @@ -75,7 +75,7 @@ android:layout_gravity="center" android:layout_marginBottom="13dp" android:textSize="42sp" - android:textColor="#242424" + android:textColor="@color/bml_receipt_amount" android:fontFamily="@font/sofia_pro" android:gravity="center" /> @@ -109,66 +109,66 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + @@ -199,7 +199,7 @@ android:layout_height="wrap_content" android:orientation="vertical" android:gravity="center" - android:background="#F5F5F5" + android:background="@color/bml_receipt_footer" android:paddingVertical="22dp"> - + - + + android:layout_height="160dp" + android:background="@drawable/mib_receipt_header_bg"> - - - - - - - - - - - - - - - - + android:paddingHorizontal="24dp"> - + + android:scaleType="fitCenter" + android:contentDescription="@null" /> @@ -109,22 +70,25 @@ - + + android:scaleType="fitCenter" + android:contentDescription="@null" /> @@ -140,81 +106,92 @@ - + - - + + + android:paddingTop="14dp" /> - + - + - - - + + - + - + - - - + + - + - + - - - + + - + + + + + + + + + + + + + + + - - - + + - + - + - - - + + - + - + - - - + + - - - - + android:contentDescription="Maldives Islamic Bank" /> diff --git a/app/src/main/res/layout/fragment_settings_about.xml b/app/src/main/res/layout/fragment_settings_about.xml index b43a4d1..8366b51 100644 --- a/app/src/main/res/layout/fragment_settings_about.xml +++ b/app/src/main/res/layout/fragment_settings_about.xml @@ -109,7 +109,7 @@ android:layout_width="32dp" android:layout_height="32dp" android:layout_marginEnd="16dp" - android:src="@drawable/bml_icon" + android:src="@drawable/bml_logo_vector" android:scaleType="fitCenter" android:contentDescription="BML" /> diff --git a/app/src/main/res/layout/fragment_settings_appearance.xml b/app/src/main/res/layout/fragment_settings_appearance.xml index 3399ca0..f6a76d6 100644 --- a/app/src/main/res/layout/fragment_settings_appearance.xml +++ b/app/src/main/res/layout/fragment_settings_appearance.xml @@ -282,6 +282,7 @@ android:id="@+id/languageToggle" android:layout_width="match_parent" android:layout_height="wrap_content" + android:baselineAligned="false" app:singleSelection="true" app:selectionRequired="true"> @@ -303,6 +304,34 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/view_otp_preview.xml b/app/src/main/res/layout/view_otp_preview.xml new file mode 100644 index 0000000..f8a1fcc --- /dev/null +++ b/app/src/main/res/layout/view_otp_preview.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..2a92fbb --- /dev/null +++ b/app/src/main/res/values-night/colors.xml @@ -0,0 +1,19 @@ + + + + #191A1C + #DEDEE0 + #DEDEE0 + #DEDEE0 + #000000 + #3D3E40 + #1E1F21 + + + #1A1A3C + #FFFFFF + #FFFFFF + #33335C + #FFFFFF + #000000 + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 184950d..4939a69 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -5,4 +5,24 @@ #9AD141 #E85D04 #E8B547 + + + #FFFFFF + #2D2D2D + #242424 + #000000 + #F5F5F5 + #E9E9E9 + #EBEBEB + #E21B23 + + + #FFFFFF + #000000 + #000000 + #CAC4D0 + #000000 + #1EA833 + #FFFFFF + #006FFC diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d7ab4ce..3c07ba0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -129,7 +129,6 @@ Failed to save image Include phone number Reference (optional) - PayMV QR Transfer Lock app @@ -160,6 +159,8 @@ Dark Pitch Black Accent Color + Receipts + Always show full screen receipt Blue Red Green @@ -297,6 +298,7 @@ Looking up merchant… Could not load merchant details + Could not load BML payment for this transaction ID Payment Successful Select a BML account to pay from diff --git a/docs/fahipayapi/07-contacts.md b/docs/fahipayapi/07-contacts.md index b70e60f..d0ee7db 100644 --- a/docs/fahipayapi/07-contacts.md +++ b/docs/fahipayapi/07-contacts.md @@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group: --- -[← Profile Picture](06-profile-picture.md) +[← Profile Picture](06-profile-picture.md)     **Next →** [PayMV QR](08-paymv-qr.md) diff --git a/docs/fahipayapi/08-paymv-qr.md b/docs/fahipayapi/08-paymv-qr.md new file mode 100644 index 0000000..7c4eea4 --- /dev/null +++ b/docs/fahipayapi/08-paymv-qr.md @@ -0,0 +1,40 @@ +# PayMV QR (Receive) + +> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress). + +Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified. + +--- + +## Endpoint + +``` +GET api/app/qr/?lang=&type=p2p&amount= +``` + +The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields. + +## Response + +The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`. + +## The card image + +A 1240 × 1322 PNG: +- Blue `#005DA3` card with a 6 px border. +- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right. +- A blue panel holding the holder's name and the QR. +- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it. +- A "MALDIVES NATIONAL QR" footer. + +Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard). + +Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples). + +--- + +  + +--- + +[← Saved Favourites](07-contacts.md) diff --git a/docs/fahipayapi/README.md b/docs/fahipayapi/README.md index 42aecf6..ec44f55 100644 --- a/docs/fahipayapi/README.md +++ b/docs/fahipayapi/README.md @@ -127,6 +127,7 @@ Client Server | 5 | [Transaction History](05-history.md) | Paginated activity/transaction history | | 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) | | 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service | +| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress | --- diff --git a/docs/thijooree/10-otp-screen.md b/docs/thijooree/10-otp-screen.md index d117e8d..b457c5e 100644 --- a/docs/thijooree/10-otp-screen.md +++ b/docs/thijooree/10-otp-screen.md @@ -21,6 +21,29 @@ Each card shows: Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead. +--- + +## Seed Actions + +Long-pressing a card opens a menu with: + +### Export seed + +A dialog titled `{bank} · {name}` showing: +- A QR code of a minimal `otpauth://totp/{BANK}?secret=…` link (e.g. `otpauth://totp/BML?secret=…`), always drawn black-on-white so it scans in dark mode. No username or issuer is included, and algorithm, digits and period are left out because SHA1, 6 and 30s are the spec defaults. Export is single-account only; `otpauth-migration://` is supported for import but never produced. +- The Base32 seed in groups of 4 (selectable text) +- A **Copy seed** button. The copy is flagged `EXTRA_IS_SENSITIVE`, so Android 13+ hides the value in the clipboard preview. + +### Update seed + +Replaces the stored seed for that login, e.g. after re-enrolling the authenticator with the bank. +- A red warning banner explains that the old seed is deleted permanently. +- The new seed can be typed or pasted (raw Base32 or an `otpauth://` link) or scanned with the QR button. Scans that contain several accounts (`otpauth-migration://`) ask which one to use. +- Once the input is a valid seed, a live preview shows its current and next code with a countdown so the user can check it against the bank before saving. The preview is the same card as the sign-in screen (`view_otp_preview.xml`, shared by both), and tapping it copies the code. Input that isn't valid Base32, is shorter than 8 characters (such as a pasted 6-digit code), or matches the current seed disables **Replace**. +- **Replace** asks for confirmation ("Delete old seed?"). Confirming calls `CredentialStore.updateMibOtpSeed()` / `updateBmlOtpSeed()`, which overwrite only the encrypted seed. For MIB it also calls `MibLoginFlow.updateOtpSeed()` so silent re-login uses the new seed. + +The username, password and sessions are not touched. Other places that need an OTP (transfers, QR pay, pay with card) read the seed from `CredentialStore` each time, so they pick up the new seed immediately. + ### Algorithm Standard RFC 6238 TOTP: @@ -58,7 +81,7 @@ The OTP screen is informational — the user copies the displayed code manually ## Security -The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. +The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. They leave the app only when the user chooses **Export seed**. --- diff --git a/docs/thijooree/11-paymv-qr-screen.md b/docs/thijooree/11-paymv-qr-screen.md index 9a39072..7830dc5 100644 --- a/docs/thijooree/11-paymv-qr-screen.md +++ b/docs/thijooree/11-paymv-qr-screen.md @@ -2,37 +2,102 @@ Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md). +> **Fahipay QRs are a work in progress.** Thijooree's Fahipay card matches Fahipay's design, but the Fahipay app currently rejects the QRs it generates as **"Invalid QR"**. BML QRs scan fine. See [PayMV QR Format → Fahipay](18-paymv-qr-format.md#fahipay-work-in-progress). + --- ## Fragment — `PayMvQrFragment` -A single screen (no tabs). Re-renders the QR live as the user edits the form. +A single screen (no tabs). Re-renders the QR live (300 ms debounce) as the user edits the form. It can also be opened for a saved contact with `PayMvQrFragment.forContact(accountNumber, name, bank)`. The QR then pays into the contact's account, and the account picker and phone toggle are hidden. ### Fields | Field | Source / behaviour | |---|---| -| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB and BML USD currently excluded — both flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set | -| Amount (`etAmount`) | Optional. Blank → open-amount QR | -| Reference (`etReference`) | Free-text purpose; defaults to `paymvqr_reference_default` if blank — written to tag 62→08 | -| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05 (auto-prefixed `+960` if 7-digit local) | +| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB, M-Faisa and BML USD currently excluded — flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set | +| Amount (`etAmount`) | Optional. Blank / zero / unparseable → open-amount QR. Commas are stripped | +| Reference (`etReference`) | Free-text purpose, written to tag 62→08. Blank → tag omitted (BML), or `PAYMENT` (Fahipay) | +| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05, normalised to `+960XXXXXXX` | -### Generation +### What goes on the card -`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md): +| Item | Value | +|---|---| +| Name | BML / MIB: `accountBriefName`. Fahipay: the holder's full name (`profileName`, falling back to the saved Fahipay profile's `fullName`) — **not** the generic "Fahipay Wallet" brief name. Contacts: the contact's name. Always uppercased | +| QR | The payload below, white modules on the card blue, error correction M, no quiet zone | +| Vertical text | The QR's reference (tag 62→05) — see below | +| Amount | **Not printed** on the card (neither bank does); it only appears inside the QR, and in BML's vertical text | -1. Tag 26 container: GUI (`mv.favara.mpqr`), acquirer BIC, account number, optional mobile, `IPAY` -2. Acquirer BIC is derived from the source account's bank: `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay) -3. Tag 62 container: random 9-char reference + the purpose text -4. Tag 80 container: GUI + ISO timestamp -5. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string +### Vertical text (reference) -The rendered card image (bank-styled background plus QR) is shown in-place. +Both banks print a short code vertically beside the QR, reading bottom-to-top. It is the same string as the payload's reference, **tag 62→05**, so Thijooree calculates the reference first and uses it for both. + +| Bank | Reference / vertical text | Example | +|---|---|---| +| BML | Account number converted to **base-32** (digits `0-9A-V`, uppercase), followed by the **amount exactly as typed** (commas removed, no forced decimals), capped at 25 chars | `7730000188362` → `70V3UKKUA`; with amount `100` → `70V3UKKUA100` | +| Fahipay | `P` + 9 random uppercase alphanumeric chars. **No amount** | `P2KVTPYL4E` | +| Other (MIB contacts) | 9 random uppercase alphanumeric chars | `WHQS0SX5O` | + +BML's base-32 is `AccountNumbertoBase32` from the BML app: `BigInt(account)`, repeatedly `% 32` into the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`. If the account number isn't numeric, Thijooree falls back to a random 9-char reference. + +--- + +## Card Rendering + +Two renderers, chosen by the target's bank. Both return a `Bitmap` shown in `ivQrCard` (`fitCenter`) and used for Share / Save. + +### BML (and MIB) — `renderQrCard()` + +A 1:1 copy of BML app v2.1.47's `ReceiveCard` React Native component (decompiled from the Hermes bundle). All values are BML's StyleSheet values in dp, laid out for a 560 dp reference screen width (`SCREEN_WIDTH_DP`) and drawn at 2 px/dp (`PX_PER_DP`), so the card comes out about 1024 px wide. + +| Element | Spec | +|---|---| +| Colour | `mmaBlue` `#0E5CA4` everywhere | +| Card | Width `sw − 48`. Blue background, radius 20. The white top section is inset 2 dp (top corners 18), which shows as a thin blue border | +| Header row | 32 dp from top, 40 dp side margins. Left: `bml_logo_paymv` ("BANK OF MALDIVES" wordmark, from BML's assets) contained in `0.38·sw × 0.38·sw·0.1117`. Right: "PayMV QR", Sofia Pro Bold, `#0E5CA4`, sized to BML's `0.2·sw × 0.2·sw·0.1733` image box, shifted down 1 dp. MIB uses `mib_faisanet_logo` in the same box | +| QR panel | 24 dp below the header, 40 dp side margins, radius 16, 24 dp bottom margin | +| Name | Roboto (system default) regular, 14 sp, white; 8 + 12 dp above, 16 dp below | +| QR | `0.5·sw` square; 37 dp padding below | +| Vertical text | Roboto 10 sp, **black at 80 % opacity**, rotated −90°. Centred `railW/1.37 − railW/2` right of the QR's right edge and `(qr + railW/1.5)/2` down from the QR top, where `railW = sw/1.85`. Ellipsized to `railW` | +| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold (`res/font/sofia_pro_bold.ttf`), `0.046·sw`, letter spacing 1.2 dp, 12 dp vertical padding | + +### Fahipay — `renderFahipayQrCard()` + +Fahipay's app doesn't draw its card; it shows an image generated by Fahipay's server (`api/app/qr/`). Thijooree copies that image's layout, measured in pixels on its **1240 × 1322** canvas. Text is sized so capital letters match the measured cap heights. + +| Element | Spec | +|---|---| +| Colour | `#005DA3` | +| Card | Blue, radius 50. White area inset 6 px (top corners 44) down to y 1174. The blue below it is the footer | +| Header row | Square app icon `fahipay_logo` at (94, 94)–(163, 163), then the "FahiPay" wordmark `fahipay_logo_long` at x 178, y 104, 48 px tall — **both, side by side** | +| "PayMV QR" | Sofia Pro Bold, blue, right-aligned at x 1147, cap height 30 (cap top y 101) | +| QR panel | (166, 218)–(1074, 1126), radius 55 | +| Name | Roboto regular, white, centred at x 619, cap height 30 (cap top y 314). Shrinks to fit the panel minus 80 px | +| QR | 562 px at (338, 417) | +| Vertical text | Montserrat Regular (`res/font/montserrat_regular.ttf`), **blue**, cap height 27, rotated −90°. It sits in the **white margin right of the panel**: text starts at y 1087, baseline at x 1171 | +| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold, white, cap height 55.5 (cap top y 1220), BML's letter spacing (1.2/25.76 em) | + +Fonts follow the BML card (Sofia Pro Bold, Roboto), except the vertical text, which keeps Fahipay's Montserrat. + +--- + +## Generation + +`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md#generating-a-receive-payment-qr): + +1. Tag 01: `11` (static). Fahipay QRs with an amount use `12` (dynamic) +2. Tag 26: GUI (`mv.favara.mpqr`), acquirer BIC ×2 — `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay), account number, optional mobile, `IPAY` +3. Tag 54: amount as `%.2f`. If there's no amount: omitted (BML), `***` (Fahipay) +4. Tag 59: name, uppercased, max 25 chars +5. Tag 60: Fahipay only — `LD` + 4 random digits +6. Tag 62: the reference (above) + purpose +7. Tag 80: GUI + timestamp `yyyy-MM-dd'T'HH:mm:ss.00000` +8. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string ### Actions -- **Share** (`btnShare`) — exports the rendered card via `FileProvider` + `ACTION_SEND` -- **Save** (`btnSave`, `PayMvQrFragment.kt:78`) — writes the PNG to `MediaStore.Images` / `Pictures/` +- **Share** (`btnShare`) — writes `_paymv_qr.png` to the cache and shares it via `FileProvider` + `ACTION_SEND` +- **Save** (`btnSave`) — writes `_PayMV_QR.png` to `MediaStore.Images` / `Pictures/` --- diff --git a/docs/thijooree/18-paymv-qr-format.md b/docs/thijooree/18-paymv-qr-format.md index 7193fce..fce66e3 100644 --- a/docs/thijooree/18-paymv-qr-format.md +++ b/docs/thijooree/18-paymv-qr-format.md @@ -31,10 +31,10 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di | `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs | | `52` | Merchant category code | `"0000"` (generic) | | `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) | -| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs | +| `54` | Transaction amount | Decimal string (e.g. `"1.50"`). Open-amount QRs: absent (Thijooree BML) or `"***"` (BML's and Fahipay's own QRs) | | `58` | Country code | `"MV"` | -| `59` | Merchant / recipient name | Max 25 characters | -| `60` | Merchant city / store code | BML POS QRs only | +| `59` | Merchant / recipient name | Max 25 characters, uppercase in every real QR seen | +| `60` | Merchant city / store code | `LD` + 4 digits (e.g. `LD0442`, `LD0745`). Seen in BML POS QRs and in BML's and Fahipay's own receive QRs; meaning of the digits unknown | | `62` | Additional data field | Container — see sub-tags below | | `63` | CRC | `6304` prefix + 4-char hex checksum — always last | | `80` | Supplementary data | Container — timestamp and domain | @@ -66,8 +66,8 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di | Sub-Tag | Field | Notes | |---|---|---| -| `05` | Reference / bill number | 9 random uppercase alphanumeric characters | -| `08` | Payment purpose | Free-form text entered by the payee | +| `05` | Reference / bill number | Bank-specific — see [Reference (tag 62→05)](#reference-tag-6205). Also printed vertically on the QR card | +| `08` | Payment purpose | Free-form text entered by the payee. BML's app defaults it to `Quickpay Transfer`, Fahipay to `PAYMENT` | --- @@ -77,6 +77,7 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di |---|---|---| | `00` | Domain | `"mv.favara.mpqr"` | | `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` | +| `02` | Unknown | `"0005"` — only seen in Fahipay's own QR **with an amount**; not generated by Thijooree | --- @@ -115,18 +116,98 @@ To create a QR that others can scan to pay you: 10 04 IPAY 52 04 0000 ← MCC 53 03 462 ← MVR -54 ← Omit tag entirely if open-amount +54 ← "%.2f". Open amount: omit (BML) / "***" (Fahipay) 58 02 MV -59 +59 ← Uppercased +60 06 LD<4 random digits> ← Fahipay only 62 - 05 09 <9 random alphanum chars> ← Reference - 08 + 05 ← Bank-specific, see below + 08 ← Omit if blank (BML) / "PAYMENT" (Fahipay) 80 00 15 mv.favara.mpqr 01 ← Timestamp 6304 ``` +For Fahipay, tag `01` is `12` (dynamic) when an amount is set. + +--- + +## Reference (tag 62→05) + +The reference is also the **vertical text** printed beside the QR on both banks' cards, so it is calculated once and used for both (`PayMvQrFragment.generateQr()`). + +### BML — base-32 account number + amount + +``` +reference = base32(accountNumber) + amountAsTyped +``` + +- `base32` is BML's `AccountNumbertoBase32`: treat the account number as an integer and convert it to base 32 with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`, most significant digit first +- `amountAsTyped` is the amount field with commas removed and **no forced decimals** (`100` stays `100`, `100.5` stays `100.5`). Empty for open-amount QRs +- Capped at 25 characters. A non-numeric account number falls back to 9 random characters + +| Account | Amount | Reference / vertical text | +|---|---|---| +| `7730000188362` | — | `70V3UKKUA` | +| `7730000188362` | `100` | `70V3UKKUA100` | + +Confirmed by decoding a QR from BML's app: `62→05` = `70V3UKKUA`, the same as the vertical text on its card. + +### Fahipay — `P` + 9 random characters + +Fahipay's server issues references like `P135KOKXJY` and `P2KVTPYL4E`: `P` followed by 9 uppercase alphanumerics. The vertical text shows the reference only — **the amount is not appended** (confirmed on a QR carrying amount `55`). Thijooree generates `"P" + 9 random chars`. + +### Others + +9 random uppercase alphanumeric characters. + +--- + +## Real Receive QRs (Reference Samples) + +Decoded from QR images generated by the official apps (CRC verified). + +**BML app** (open amount): + +``` +00020101021126920014mv.favara.mpqr0108MALBMVMV0208MALBMVMV031377300001883620511+96091980261004IPAY6006LD04425204000053034625403***5802MV5915SHIHAM A.RAHMAN6234050970V3UKKUA0817Quickpay Transfer80470014mv.favara.mpqr01252026-09-26T02:29:53.000006304F8E6 +``` + +Note that BML's app places tag `60` *inside* tag `26` here (after `10 IPAY`, as `6006LD0442`). + +**Fahipay app**, open amount: + +``` +00020101021126810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125008500611080511+96098074051004IPAY5204000053034625403***5802MV5912MOHAMED RAIF6006LD074562250510P135KOKXJY0807PAYMENT80470014mv.favara.mpqr01252026-09-26T03:44:36.0000063042707 +``` + +**Fahipay app**, amount `55`: + +``` +00020101021226810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125003600510030511+96091980261004IPAY5204000053034625402555802MV5919SHIHAM ABDUL RAHMAN6006LD097062250510P2KVTPYL4E0807PAYMENT80550014mv.favara.mpqr01252026-09-26T03:22:08.000000204000563045304 +``` + +--- + +## Fahipay (Work in Progress) + +> ⚠️ **Fahipay QRs generated by Thijooree do not work yet.** The Fahipay app rejects them as **"Invalid QR"**. BML QRs from Thijooree scan fine in the BML app. + +Fahipay's app doesn't build its QR locally: it fetches it from `GET api/app/qr/?lang=…&type=p2p&amount=…`, and the server returns the finished card image and payload. Things tried so far, with the Fahipay app still reporting invalid: + +| Change | Status | +|---|---| +| Mobile `26→05` normalised from Fahipay's stored `960XXXXXXX` to `+960XXXXXXX` | Done (was a real bug) | +| Name `59` uppercased | Done | +| `54` = `***` for open amount, `01` = `12` with an amount | Done | +| `60` = `LD` + 4 random digits | Done | +| `62→08` defaults to `PAYMENT` | Done | +| `62→05` shaped `P` + 9 chars | Done | +| `80→02` = `0005` (amount QRs only) | Not done | + +The CRC is correct (verified against all samples). With no amount, Thijooree's payload now has the same fields in the same order as Fahipay's own. The leading theory is that Fahipay's scanner looks up the `P…` reference on Fahipay's server, which issued it. If so, no locally generated QR can pass, and the fix would be to fetch the payload from `api/app/qr/` and render the card around it. + --- ## Parsing a PayMV QR (Incoming Scan) @@ -219,11 +300,11 @@ exactly one request is made either way. ## Example Payload -Static QR for account `7700000000123`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`: +Static BML QR for account `7730000188362`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`: ``` 000201010211268...520400005303462 -5802MV5909AHMED ALI6225050912345ABCDEF0804Rent +5802MV5909AHMED ALI6221050970V3UKKUA0804Rent 80...63044A2B ``` diff --git a/docs/thijooree/README.md b/docs/thijooree/README.md index 3818984..5ea5200 100644 --- a/docs/thijooree/README.md +++ b/docs/thijooree/README.md @@ -19,7 +19,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine | [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet | | [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt | | [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators | -| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) | +| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR, BML/Fahipay card rendering, vertical reference text (Fahipay QRs WIP) | | [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) | | [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits | | [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About | @@ -39,7 +39,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine | Document | Description | |---|---| -| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, QR generation recipe, parsing reference | +| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference | | [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model | | [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments | | [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy | diff --git a/docs/thijooree/faq/README.md b/docs/thijooree/faq/README.md new file mode 100644 index 0000000..8133499 --- /dev/null +++ b/docs/thijooree/faq/README.md @@ -0,0 +1,3 @@ +# FAQ + +## [What is and how do i get my TOTP Seed?](totpseed/README.md) diff --git a/docs/thijooree/faq/totpseed/01-setup-bml.md b/docs/thijooree/faq/totpseed/01-setup-bml.md new file mode 100644 index 0000000..6c05d47 --- /dev/null +++ b/docs/thijooree/faq/totpseed/01-setup-bml.md @@ -0,0 +1,87 @@ +# Set up BML + +Reset your Bank of Maldives authenticator to get a new OTP seed, then add that seed to Thijooree so it can generate your OTP codes. + +> [!NOTE] +> You need the BML app signed in, and a BML debit card with its expiry date and CVC to confirm who you are. + +> [!IMPORTANT] +> Want the same codes in another authenticator app too, such as Microsoft Authenticator or Google Authenticator? Add the secret key to that app **after step 5 and before step 7**. After you tap **Verify Code**, BML stops showing the secret key and you would have to reset again. See [Export from Microsoft Authenticator](04-export-microsoft.md) for the steps. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Step 1Step 2Step 3Step 4
BML app wallet screen with the profile icon highlightedProfile menu with Authenticator Setup highlightedChannel Settings screen with the Reset Authenticator button highlightedDebit card verification form with the Authorize button highlighted
+ Open your profile
+ In the BML app, tap the profile icon in the top-right corner of the Wallet screen. +
+ Open Authenticator Setup
+ In the menu, under Settings, tap Authenticator Setup. +
+ Reset the authenticator
+ On the Security tab, tap Reset Authenticator. This replaces any authenticator app you used before. +
+ Verify your debit card
+ Pick a debit card, enter its expiry month, expiry year and CVC, then tap Authorize. +
Step 5Step 6Step 7Done
QR code screen with the copy button next to the secret key highlightedThijooree sign-in screen with the OTP seed filled in and the current OTP shownBML screen with the OTP entered and the Verify Code button highlightedAuthenticator reset successfully message
+ Copy the secret key
+ Below the QR code, tap the copy button next to the secret key. Keep this screen open, you will come back to it. +
+ Add the seed to Thijooree
+ Open Thijooree and paste the key into OTP Seed (TOTP Secret). Tap the Current OTP box to copy the 6-digit code.

+ Adding the key to another authenticator app? Do it now, before step 7. +
+ Verify the code in BML
+ Go back to the BML app, paste the code into the 6-digit code field and tap Verify Code. +
+ All done
+ BML shows Authenticator reset successfully. Thijooree now generates your BML OTP codes. +
+ +## Log in to Thijooree + +Once BML shows **Authenticator reset successfully**, go back to Thijooree: + +1. Enter your BML **Username** and **Password**. +2. Check that **OTP Seed (TOTP Secret)** still has the key you pasted in step 6. +3. Tap **Login**. + +> [!TIP] +> The OTP changes every 30 seconds. If BML rejects the code, copy the current one from Thijooree again and verify straight away. + +> [!WARNING] +> The secret key gives full access to your OTP codes. Don't share it or screenshot it where others can see it. diff --git a/docs/thijooree/faq/totpseed/02-setup-mib.md b/docs/thijooree/faq/totpseed/02-setup-mib.md new file mode 100644 index 0000000..09c6597 --- /dev/null +++ b/docs/thijooree/faq/totpseed/02-setup-mib.md @@ -0,0 +1,39 @@ +# Set up MIB + +Maldives Islamic Bank doesn't let you reset your authenticator from the app. The only way to get a new OTP seed is to ask customer care, and then wait. And wait some more. + +> [!NOTE] +> You need patience, a working email address and a small amount of faith. Results may vary. + +## How to do it + +1. **Contact customer care**
+ Get in touch with MIB customer care and ask them to reset your authenticator and send you a new secret key. + +2. **Perform the summoning ritual**
+ Light a candle, face the direction of the nearest MIB branch and chant *"please reply, please reply"* three times. Offering a cup of tea to the ticket gods is optional but recommended. + +3. **Wait for the email**
+ MIB emails you the new secret key. Eventually. Check your inbox, then check your spam folder, then check your inbox again. + +4. **Wait more**
+ Still nothing? This is normal. Refresh your inbox. Touch grass. Refresh your inbox again. + +5. **Perform another ritual**
+ Repeat step 2, but with two candles this time. If it has been a few working days, a polite follow-up to customer care also works, and is less of a fire hazard. + +6. **Add the seed to Thijooree**
+ Once the email arrives, copy the secret key from it. Open Thijooree, go to the **faisanet** sign-in screen and paste the key into **OTP Seed (TOTP Secret)**. The **Current OTP** appears below it. + +## Log in to Thijooree + +Once the key is in, in Thijooree: + +1. Enter your MIB **Username** and **Password**. +2. Tap **Login**. + +> [!TIP] +> Already have your MIB account in Google Authenticator or Bitwarden? Skip the rituals entirely and see [Export from Google Authenticator](03-export-googleauthenticator.md) or [Export from Bitwarden](05-export-bitwarden.md). + +> [!WARNING] +> The secret key gives full access to your OTP codes. Don't share it, and delete the email once you have logged in. diff --git a/docs/thijooree/faq/totpseed/03-export-googleauthenticator.md b/docs/thijooree/faq/totpseed/03-export-googleauthenticator.md new file mode 100644 index 0000000..96d8355 --- /dev/null +++ b/docs/thijooree/faq/totpseed/03-export-googleauthenticator.md @@ -0,0 +1,107 @@ +# Export from Google Authenticator + +Google Authenticator can export your accounts as a QR code. Take a screenshot of that QR code and load it into Thijooree to get the same OTP seed, without resetting anything with your bank. + +> [!NOTE] +> Exporting doesn't change your seed. Google Authenticator keeps working, and it shows the same codes as Thijooree. + +> [!IMPORTANT] +> Thijooree holds one seed per login. In step 3, select **only** the bank account you want to log in to on Thijooree. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Step 1Step 2Step 3Step 4
Google Authenticator home screen with the menu button highlightedGoogle Authenticator menu with Transfer codes highlightedSelect codes screen with only MIB checked and the Next button highlightedScan this QR code screen showing the export QR code
+ Open the menu
+ In Google Authenticator, tap the menu button (three lines) in the top-left corner. +
+ Open Transfer codes
+ Tap Transfer codes. +
+ Select your bank account
+ Check only the bank account you want to log in to on Thijooree, then tap Next. +
+ Screenshot the QR code
+ Take a screenshot of the QR code. Keep this screen open, you will come back to it. +
Step 5Step 6Step 7Step 8
Thijooree sign-in screen with the QR button next to the OTP seed field highlightedThijooree QR scanner with the Pick image button highlightedPhoto picker with the QR code screenshot selectedGoogle Authenticator Scan this QR code screen with the Next button highlighted
+ Open the QR scanner
+ Open Thijooree and, on the sign-in screen, tap the QR button next to OTP Seed (TOTP Secret). +
+ Pick an image
+ The QR code is on the same phone, so there is nothing to scan. Tap Pick image. +
+ Select the screenshot
+ Select the QR code screenshot from step 4. Thijooree fills in the OTP seed for you. +
+ Finish the export
+ Go back to Google Authenticator and tap Next on the QR code screen. +
Step 9Step 10
Remove your exported codes screen with Keep exported codes and Done highlightedGoogle Authenticator list with the MIB code highlightedThijooree sign-in screen with the Current OTP highlighted, matching Google Authenticator
+ Keep the exported codes
+ Select Keep exported codes and tap Done. Don't remove them, or the account disappears from Google Authenticator. +
+ Check that the codes match
+ Compare the Current OTP in Thijooree with the code for the same account in Google Authenticator. They should be the same, because both use the same seed. +
+ +## Log in to Thijooree + +Once the codes match, in Thijooree: + +1. Enter your bank **Username** and **Password**. +2. Tap **Login**. + +> [!TIP] +> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, go back to step 3 and check you selected the right account. + +> [!WARNING] +> The QR code screenshot holds your OTP seed. Delete it from your phone, and from any cloud photo backup, once you have logged in. diff --git a/docs/thijooree/faq/totpseed/04-export-microsoft.md b/docs/thijooree/faq/totpseed/04-export-microsoft.md new file mode 100644 index 0000000..aee9be0 --- /dev/null +++ b/docs/thijooree/faq/totpseed/04-export-microsoft.md @@ -0,0 +1,62 @@ +# Export from Microsoft Authenticator + +Microsoft Authenticator can't export TOTP seeds unless your phone is rooted, so you can't move an existing seed out of it. + +Instead, reset your OTP seed with your bank to get a new secret key. Add that key to Thijooree, and to Microsoft Authenticator too if you want codes in both apps. + +> [!IMPORTANT] +> Resetting replaces the old seed. The existing BML entry in Microsoft Authenticator stops working, so you have to add the new key to it again. + +## How to do it + +1. Follow [Set up BML](01-setup-bml.md) up to **step 5**, where you copy the secret key. +2. Add the key to Microsoft Authenticator using the steps below. +3. Go back to [Set up BML](01-setup-bml.md) and continue from **step 6**. + +> [!WARNING] +> Add the key to Microsoft Authenticator **before step 7** (Verify Code). After you verify, BML stops showing the secret key and you would have to reset again. + +## Add the key to Microsoft Authenticator + + + + + + + + + + + + + + + + + + + + + + + +
Step 1Step 2Step 3Step 4Done
Microsoft Authenticator home screen with the QR code button highlightedScan QR Code screen with the Enter code manually button highlightedAdd account screen with Other account highlightedAdd account form with account name and secret key filled inBank of Maldives account in the Microsoft Authenticator list showing a 6-digit code
+ Add an account
+ Open Microsoft Authenticator and tap the QR code button in the bottom-right corner. +
+ Enter the code manually
+ BML is on the same phone, so there is nothing to scan. Tap Enter code manually. +
+ Choose the account type
+ Tap Other account (Google, Facebook, etc.). +
+ Paste the secret key
+ Enter an Account Name such as Bank of Maldives, paste the key from BML into Secret Key and tap Finish. +
+ Account added
+ The account now shows a 6-digit code. It matches the code in Thijooree because both use the same key. +
+ +Now go back to [Set up BML](01-setup-bml.md) and continue from **step 6**. In step 7 you can verify with the code from either Thijooree or Microsoft Authenticator. + +For MIB, see [Set up MIB](02-setup-mib.md). diff --git a/docs/thijooree/faq/totpseed/05-export-bitwarden.md b/docs/thijooree/faq/totpseed/05-export-bitwarden.md new file mode 100644 index 0000000..497759a --- /dev/null +++ b/docs/thijooree/faq/totpseed/05-export-bitwarden.md @@ -0,0 +1,52 @@ +# Export from Bitwarden + +Bitwarden stores the authenticator key of a login in plain text. Copy it from the login's edit screen and paste it into Thijooree to get the same OTP seed, without resetting anything with your bank. + +> [!NOTE] +> Copying the key doesn't change your seed. Bitwarden keeps working, and it shows the same codes as Thijooree. + + + + + + + + + + + + + + + + + + + + +
Step 1Step 2Step 3Step 4
Bitwarden View login screen for Bank of Maldives with the edit button highlightedBitwarden Edit login screen with the copy button next to the Authenticator key highlightedThijooree sign-in screen with the OTP seed filled in and the current OTP shownBitwarden View login screen with the Authenticator key code highlighted, matching Thijooree
+ Edit your bank login
+ In Bitwarden, open the login for your bank and tap the edit button in the bottom-right corner. +
+ Copy the authenticator key
+ Tap the copy button next to Authenticator key. +
+ Add the seed to Thijooree
+ Open Thijooree and paste the key into OTP Seed (TOTP Secret). The Current OTP appears below it. +
+ Check that the codes match
+ Go back to Bitwarden, close the edit screen without saving and compare the Authenticator key code with the Current OTP in Thijooree. They should be the same, because both use the same seed. +
+ +## Log in to Thijooree + +Once the codes match, in Thijooree: + +1. Enter your bank **Username** and **Password**. +2. Tap **Login**. + +> [!TIP] +> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, copy the key from Bitwarden again and make sure you opened the right login. + +> [!WARNING] +> The authenticator key gives full access to your OTP codes. Don't share it or paste it anywhere else. diff --git a/docs/thijooree/faq/totpseed/README.md b/docs/thijooree/faq/totpseed/README.md new file mode 100644 index 0000000..9a5981d --- /dev/null +++ b/docs/thijooree/faq/totpseed/README.md @@ -0,0 +1,27 @@ +# TOTP Seed + +## What is a TOTP seed? + +A TOTP (Time-based One-Time Password) seed is the secret key your bank gives you when you set up its authenticator. It looks like a string of capital letters and numbers such as `JBSWY3DPEHPK3PXP`, or comes inside a QR code as an `otpauth://` link. + +## Thijooree needs the seed to: +- Enable OTP-less Transactions. +- Show your OTP codes (to use official bank app or website along with Thijooree.) + +To keep transactions secure, you can have Thijooree ask for your biometrics instead of an OTP. \ +Keep your seed private: anyone who has it can make your OTP codes. + +## How do I get my TOTP seed? + +You get your seed in one of two ways: + +### Setup + +1. [Set up BML](01-setup-bml.md) +2. [Set up MIB](02-setup-mib.md) + +### Export + +3. [Export from Google Authenticator](03-export-googleauthenticator.md) +4. [Export from Microsoft Authenticator](04-export-microsoft.md) +5. [Export from Bitwarden](05-export-bitwarden.md) diff --git a/docs/thijooree/faq/totpseed/screenshots/bitwarden_1.jpg b/docs/thijooree/faq/totpseed/screenshots/bitwarden_1.jpg new file mode 100644 index 0000000..ce15bbe Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bitwarden_1.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bitwarden_2.jpg b/docs/thijooree/faq/totpseed/screenshots/bitwarden_2.jpg new file mode 100644 index 0000000..f9a38cc Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bitwarden_2.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bitwarden_3.jpg b/docs/thijooree/faq/totpseed/screenshots/bitwarden_3.jpg new file mode 100644 index 0000000..980c310 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bitwarden_3.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bml_1.jpg b/docs/thijooree/faq/totpseed/screenshots/bml_1.jpg new file mode 100644 index 0000000..a22e69b Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bml_1.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bml_2.jpg b/docs/thijooree/faq/totpseed/screenshots/bml_2.jpg new file mode 100644 index 0000000..1dce489 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bml_2.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bml_3.jpg b/docs/thijooree/faq/totpseed/screenshots/bml_3.jpg new file mode 100644 index 0000000..cc1ff42 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bml_3.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bml_4.jpg b/docs/thijooree/faq/totpseed/screenshots/bml_4.jpg new file mode 100644 index 0000000..6676d0d Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bml_4.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bml_5.jpg b/docs/thijooree/faq/totpseed/screenshots/bml_5.jpg new file mode 100644 index 0000000..2d7c605 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bml_5.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bml_6.jpg b/docs/thijooree/faq/totpseed/screenshots/bml_6.jpg new file mode 100644 index 0000000..d71821a Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bml_6.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/bml_7.jpg b/docs/thijooree/faq/totpseed/screenshots/bml_7.jpg new file mode 100644 index 0000000..96e33da Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/bml_7.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/google_1.jpg b/docs/thijooree/faq/totpseed/screenshots/google_1.jpg new file mode 100644 index 0000000..488c38d Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/google_1.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/google_2.jpg b/docs/thijooree/faq/totpseed/screenshots/google_2.jpg new file mode 100644 index 0000000..0d0931e Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/google_2.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/google_3.jpg b/docs/thijooree/faq/totpseed/screenshots/google_3.jpg new file mode 100644 index 0000000..1378661 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/google_3.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/google_4.jpg b/docs/thijooree/faq/totpseed/screenshots/google_4.jpg new file mode 100644 index 0000000..0269fd2 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/google_4.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/google_5.jpg b/docs/thijooree/faq/totpseed/screenshots/google_5.jpg new file mode 100644 index 0000000..7f539d5 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/google_5.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/google_6.jpg b/docs/thijooree/faq/totpseed/screenshots/google_6.jpg new file mode 100644 index 0000000..2a5ceeb Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/google_6.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/google_7.jpg b/docs/thijooree/faq/totpseed/screenshots/google_7.jpg new file mode 100644 index 0000000..c2c072a Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/google_7.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/msauth_1.jpg b/docs/thijooree/faq/totpseed/screenshots/msauth_1.jpg new file mode 100644 index 0000000..b1e158a Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/msauth_1.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/msauth_2.jpg b/docs/thijooree/faq/totpseed/screenshots/msauth_2.jpg new file mode 100644 index 0000000..8657efd Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/msauth_2.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/msauth_3.jpg b/docs/thijooree/faq/totpseed/screenshots/msauth_3.jpg new file mode 100644 index 0000000..a578c17 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/msauth_3.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/msauth_4.jpg b/docs/thijooree/faq/totpseed/screenshots/msauth_4.jpg new file mode 100644 index 0000000..1546896 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/msauth_4.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/msauth_5.jpg b/docs/thijooree/faq/totpseed/screenshots/msauth_5.jpg new file mode 100644 index 0000000..547a7ef Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/msauth_5.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/thijooree_1.jpg b/docs/thijooree/faq/totpseed/screenshots/thijooree_1.jpg new file mode 100644 index 0000000..b235c55 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/thijooree_1.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/thijooree_2.jpg b/docs/thijooree/faq/totpseed/screenshots/thijooree_2.jpg new file mode 100644 index 0000000..55c1c69 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/thijooree_2.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/thijooree_3.jpg b/docs/thijooree/faq/totpseed/screenshots/thijooree_3.jpg new file mode 100644 index 0000000..f796f59 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/thijooree_3.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/thijooree_4.jpg b/docs/thijooree/faq/totpseed/screenshots/thijooree_4.jpg new file mode 100644 index 0000000..7161543 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/thijooree_4.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/thijooree_5.jpg b/docs/thijooree/faq/totpseed/screenshots/thijooree_5.jpg new file mode 100644 index 0000000..a624e72 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/thijooree_5.jpg differ diff --git a/docs/thijooree/faq/totpseed/screenshots/thijooree_6.jpg b/docs/thijooree/faq/totpseed/screenshots/thijooree_6.jpg new file mode 100644 index 0000000..9675960 Binary files /dev/null and b/docs/thijooree/faq/totpseed/screenshots/thijooree_6.jpg differ diff --git a/fastlane/metadata/android/en-US/changelogs/29.txt b/fastlane/metadata/android/en-US/changelogs/29.txt new file mode 100644 index 0000000..a31d8ef --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/29.txt @@ -0,0 +1,5 @@ +- Fix language toggle alignment. +- Redesign Fahipay (still broken) and BML PayMV QR +- BML receipt preview dark theme support +- MIB receipt redesign with dark theme support +- New recipt full-screen mode for MIB and BML diff --git a/fastlane/metadata/android/en-US/changelogs/30.txt b/fastlane/metadata/android/en-US/changelogs/30.txt new file mode 100644 index 0000000..a8be543 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30.txt @@ -0,0 +1 @@ +- Export and Update OTP seeds diff --git a/fastlane/metadata/android/en-US/changelogs/31.txt b/fastlane/metadata/android/en-US/changelogs/31.txt new file mode 100644 index 0000000..0c9c0c8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/31.txt @@ -0,0 +1 @@ +- New setting to always show recipt full screen diff --git a/fastlane/metadata/android/en-US/changelogs/32.txt b/fastlane/metadata/android/en-US/changelogs/32.txt new file mode 100644 index 0000000..cb00256 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/32.txt @@ -0,0 +1 @@ +- Pay with BML Transaction ID (On BML Pay supported transactions"