Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e7cf2a24
|
||
|
|
97fe63025d
|
||
|
|
0e7f329a4b
|
||
|
|
2082e8fd0c
|
||
|
|
97a0cb218f
|
||
|
|
0158d6dcd8
|
||
|
|
320eaa2ffb
|
||
|
|
1886113ae7
|
||
|
|
41e7bc70e9
|
||
|
|
d786609bd1
|
||
|
|
2246e5929f
|
||
|
|
d0eee817ec
|
||
|
|
af414914c7
|
||
|
|
ec5b791a45
|
||
|
|
dd4aed0f94
|
||
|
|
fd4cdfecac
|
||
|
|
92f5af76e6
|
||
|
|
bc81255b31
|
||
|
|
acd11ef3eb
|
||
|
|
fc778f2a90
|
||
|
|
778bcc4d75
|
||
|
|
97c8033014
|
||
|
|
58d43f33d6
|
||
|
|
af791fc5ad
|
||
|
|
d8ef3a63c6
|
||
|
|
83ca37eade
|
||
|
|
da5cca79c6
|
@@ -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.
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 29
|
||||
versionName = "1.0.28"
|
||||
versionCode = 32
|
||||
versionName = "1.0.31"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -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/<id>`),
|
||||
* 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` (`<id>_<epoch millis>`);
|
||||
* - 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentChatBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactListParser
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.ChatAccount
|
||||
import sh.sar.basedbank.util.ChatStore
|
||||
import sh.sar.basedbank.util.ChatThread
|
||||
|
||||
/**
|
||||
* One person's transfers as a chat, or one business's card / Scan to Pay payments.
|
||||
*
|
||||
* A person can have several accounts (e.g. MVR and USD); the "To" bar picks which one to send to.
|
||||
* Sending opens [TransferFragment] in a sheet, prefilled with the recipient, source account,
|
||||
* amount and note, so the usual confirm/OTP flow still applies. A monthly summary sits on top,
|
||||
* and long-pressing a bubble offers Send again / receipt / copy.
|
||||
*/
|
||||
class ChatFragment : Fragment(), ContactSheetHost {
|
||||
|
||||
private var _binding: FragmentChatBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private val app get() = requireActivity().application as BasedBankApp
|
||||
|
||||
private var peerKey = ""
|
||||
private var header: ViewChatHeaderBinding? = null
|
||||
private val adapter = ChatMessagesAdapter(
|
||||
onReceiptClick = { openReceipt(it.receiptKey) },
|
||||
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
|
||||
)
|
||||
private var thread: ChatThread? = null
|
||||
private var fromAccounts: List<BankAccount> = emptyList()
|
||||
private var selectedFrom: BankAccount? = null
|
||||
private var selectedTo: ChatAccount? = null
|
||||
private var merchantQr: String? = null
|
||||
private var cardAccounts: List<BankAccount> = emptyList()
|
||||
private var selectedCard: BankAccount? = null
|
||||
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
|
||||
private var payStartedAt = 0L
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
|
||||
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
|
||||
binding.rvMessages.adapter = adapter
|
||||
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||
adapter.setHideAmounts(it)
|
||||
thread?.let { t -> bindSummary(t) }
|
||||
bindFromChip()
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
|
||||
cardAccounts = accounts.filter {
|
||||
it.bank == "BML" && it.profileType in CARD_TYPES && it.statusDesc.equals("Active", ignoreCase = true)
|
||||
}
|
||||
bindFromAccounts()
|
||||
}
|
||||
|
||||
binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
|
||||
binding.btnSaveContact.setOnClickListener { openContact() }
|
||||
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
|
||||
binding.btnSend.setOnClickListener { send() }
|
||||
binding.etNote.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
|
||||
}
|
||||
binding.btnPayAgain.setOnClickListener { payBusiness() }
|
||||
|
||||
// Transfers started from this chat come straight back here instead of opening the receipt.
|
||||
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||
onTransferDone()
|
||||
}
|
||||
|
||||
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putString(ARG_PEER_KEY, peerKey)
|
||||
}
|
||||
|
||||
private fun loadThread() {
|
||||
val ctx = requireContext().applicationContext
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
|
||||
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
|
||||
val t = threads.firstOrNull { it.peerKey == peerKey }
|
||||
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
|
||||
if (_binding == null) return@launch
|
||||
thread = t
|
||||
if (t == null) return@launch
|
||||
peerKey = t.peerKey
|
||||
adapter.showDestination = t.accounts.size > 1
|
||||
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
|
||||
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
|
||||
bindSummary(t)
|
||||
|
||||
bindFromAccounts()
|
||||
val previous = selectedTo
|
||||
val to = t.accounts.firstOrNull { it.account == previous?.account }
|
||||
?: t.accounts.firstOrNull { it.account == t.peerAccount }
|
||||
selectTo(to, matchFromCurrency = previous == null)
|
||||
updateToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
|
||||
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
|
||||
val b = _binding ?: return
|
||||
val t = thread ?: return
|
||||
selectedTo = to
|
||||
|
||||
if (t.isMerchant) {
|
||||
// Businesses paid by card / Scan to Pay: history only, nothing to send to or save.
|
||||
b.toAccountBar.visibility = View.GONE
|
||||
b.saveContactBar.visibility = View.GONE
|
||||
b.composer.visibility = View.GONE
|
||||
b.tvCannotSend.visibility = View.GONE
|
||||
b.btnPayAgain.visibility = View.VISIBLE
|
||||
bindMerchantQr(t)
|
||||
return
|
||||
}
|
||||
b.btnPayAgain.visibility = View.GONE
|
||||
// One account: it's shown in the header. Several: this bar picks between them.
|
||||
b.toAccountBar.visibility = if (to == null || t.accounts.size < 2) View.GONE else View.VISIBLE
|
||||
if (to != null) {
|
||||
b.tvToAccount.text = to.account
|
||||
b.tvToCurrency.text = to.currency
|
||||
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
|
||||
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
|
||||
b.tvToNickname.text = nickname
|
||||
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
|
||||
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
// Offer to save the selected account (or the person, when no account is known yet).
|
||||
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
|
||||
|
||||
val canSend = to != null
|
||||
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
|
||||
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
|
||||
|
||||
// Sending to a USD account: default to a USD source account, and likewise for MVR.
|
||||
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
|
||||
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
|
||||
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
|
||||
}
|
||||
updateCurrencyWarning()
|
||||
}
|
||||
|
||||
private fun showRecipientPicker() {
|
||||
val t = thread ?: return
|
||||
if (t.accounts.size < 2) return
|
||||
val ctx = requireContext()
|
||||
val dialog = BottomSheetDialog(ctx)
|
||||
val list = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||
setPadding(0, pad, 0, pad * 3)
|
||||
}
|
||||
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
|
||||
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||
for (account in t.accounts) {
|
||||
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
|
||||
?: getString(R.string.chat_not_in_contacts)
|
||||
row.tvDropdownAccountNumber.text = account.account
|
||||
row.tvDropdownBalance.text = account.currency
|
||||
row.ivDropdownCardLogo.visibility = View.GONE
|
||||
// A USD account can only be paid from a USD account.
|
||||
val unavailable = account.currency.equals("USD", ignoreCase = true) && !hasUsdSource()
|
||||
row.tvDropdownAccountType.text = getString(R.string.chat_needs_usd_source)
|
||||
row.tvDropdownAccountType.visibility = if (unavailable) View.VISIBLE else View.GONE
|
||||
row.root.alpha = if (unavailable) 0.4f else 1f
|
||||
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
|
||||
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||
if (!unavailable) row.root.setOnClickListener {
|
||||
selectTo(account, matchFromCurrency = true)
|
||||
updateToolbar()
|
||||
dialog.dismiss()
|
||||
}
|
||||
list.addView(row.root)
|
||||
}
|
||||
dialog.setContentView(list)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
|
||||
this.text = text
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
|
||||
val pad = (16 * resources.displayMetrics.density).toInt()
|
||||
setPadding(pad, pad / 2, pad, pad / 2)
|
||||
}
|
||||
|
||||
private fun bindFromAccounts() {
|
||||
val b = _binding ?: return
|
||||
if (fromAccounts.isEmpty()) return
|
||||
// Default to the account last used with this person.
|
||||
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
|
||||
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
|
||||
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
|
||||
|
||||
selectFrom(selectedFrom!!)
|
||||
}
|
||||
|
||||
private fun onTransferDone() {
|
||||
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
|
||||
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
|
||||
}
|
||||
binding.etAmount.text = null
|
||||
binding.etNote.text = null
|
||||
// The receipt shows the new bubble right away; history catches up a little later and
|
||||
// replaces it with the bank's own entry.
|
||||
sync()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
delay(HISTORY_CATCH_UP_MS)
|
||||
sync()
|
||||
}
|
||||
// Only offer the receipt this payment made; QR payments don't save one.
|
||||
val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
|
||||
val merchant = thread?.isMerchant == true
|
||||
Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
|
||||
.setAnchorView(if (binding.composer.visibility == View.VISIBLE) binding.composer else binding.btnPayAgain)
|
||||
.apply { if (receipt != null) setAction(R.string.chat_view_receipt) { openReceipt(receipt.savedAt.toString()) } }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openReceipt(receiptKey: String) {
|
||||
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
|
||||
if (entry == null) {
|
||||
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
|
||||
}
|
||||
|
||||
private fun sync() {
|
||||
val ctx = requireContext().applicationContext
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||
if (_binding != null) loadThread()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectFrom(account: BankAccount) {
|
||||
val b = _binding ?: return
|
||||
selectedFrom = account
|
||||
bindFromChip()
|
||||
b.tilAmount.prefixText = account.currencyName
|
||||
updateCurrencyWarning()
|
||||
}
|
||||
|
||||
/** The chip shows the account (or, in a business chat, the card) paid from, with its balance. */
|
||||
private fun bindFromChip() {
|
||||
val b = _binding ?: return
|
||||
val account = (if (thread?.isMerchant == true) selectedCard else selectedFrom) ?: return
|
||||
val balance = AccountListParser.from(account)?.balance.orEmpty()
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val money = when {
|
||||
balance.isBlank() -> account.currencyName
|
||||
hide -> AccountHistoryAdapter.maskAmount(balance)
|
||||
else -> balance
|
||||
}
|
||||
b.btnFrom.text = getString(R.string.chat_from_chip, account.accountBriefName, account.accountNumber, money)
|
||||
b.btnFrom.contentDescription = getString(R.string.chat_from_account) + ": " + b.btnFrom.text
|
||||
|
||||
// A card shows its network logo up front, in full colour; an account keeps the dropdown arrow.
|
||||
val logo = if (thread?.isMerchant == true) sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(account) else null
|
||||
val density = resources.displayMetrics.density
|
||||
if (logo != null) {
|
||||
b.btnFrom.setIconResource(logo)
|
||||
b.btnFrom.iconTint = null
|
||||
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_START
|
||||
b.btnFrom.iconSize = (28 * density).toInt()
|
||||
} else {
|
||||
b.btnFrom.setIconResource(R.drawable.ic_arrow_right)
|
||||
b.btnFrom.iconTint = android.content.res.ColorStateList.valueOf(
|
||||
MaterialColors.getColor(b.btnFrom, com.google.android.material.R.attr.colorOnSecondaryContainer))
|
||||
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_END
|
||||
b.btnFrom.iconSize = (16 * density).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCardPicker() {
|
||||
if (cardAccounts.isEmpty()) return
|
||||
val ctx = requireContext()
|
||||
val dialog = BottomSheetDialog(ctx)
|
||||
val list = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||
setPadding(0, pad, 0, pad * 3)
|
||||
}
|
||||
list.addView(sheetTitle(getString(R.string.chat_pick_card)))
|
||||
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||
for (card in cardAccounts) {
|
||||
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||
bindAccountRow(row, card)
|
||||
sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(card)?.let {
|
||||
row.ivDropdownCardLogo.setImageResource(it)
|
||||
row.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
}
|
||||
if (card.accountNumber == selectedCard?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||
row.root.setOnClickListener {
|
||||
selectedCard = card
|
||||
bindFromChip()
|
||||
dialog.dismiss()
|
||||
}
|
||||
list.addView(row.root)
|
||||
}
|
||||
dialog.setContentView(list)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* MVR can't be sent into a USD account: say so and disable Send. USD into an MVR account is
|
||||
* converted by BML at its rate: allowed, with a warning.
|
||||
*/
|
||||
private fun updateCurrencyWarning() {
|
||||
val b = _binding ?: return
|
||||
val from = selectedFrom?.currencyName.orEmpty()
|
||||
val to = selectedTo?.currency.orEmpty()
|
||||
val blocked = isBlockedPair(from, to)
|
||||
val converts = from.equals("USD", ignoreCase = true) && to.equals("MVR", ignoreCase = true)
|
||||
b.tvCurrencyWarning.text = when {
|
||||
blocked -> getString(R.string.chat_currency_blocked)
|
||||
converts -> getString(R.string.chat_currency_warning)
|
||||
else -> null
|
||||
}
|
||||
b.tvCurrencyWarning.visibility = if (blocked || converts) View.VISIBLE else View.GONE
|
||||
b.btnSend.isEnabled = !blocked
|
||||
}
|
||||
|
||||
/** MVR → USD account: BML doesn't allow it. */
|
||||
private fun isBlockedPair(fromCurrency: String, toCurrency: String) =
|
||||
fromCurrency.equals("MVR", ignoreCase = true) && toCurrency.equals("USD", ignoreCase = true)
|
||||
|
||||
private fun hasUsdSource() = fromAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
|
||||
|
||||
/** Looks up a saved static QR for this business: with one, the button pays it directly. */
|
||||
private fun bindMerchantQr(t: ChatThread) {
|
||||
val ctx = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val qr = withContext(Dispatchers.IO) { ChatStore.merchantQr(ctx, t.peerName) }
|
||||
val b = _binding ?: return@launch
|
||||
merchantQr = qr
|
||||
b.btnPayAgain.text = if (qr != null) getString(R.string.chat_pay_business, t.peerName)
|
||||
else getString(R.string.chat_pay_again)
|
||||
// A saved QR is paid from a card picked here: show just the card chip above the button.
|
||||
b.composer.visibility = if (qr != null && cardAccounts.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
b.amountRow.visibility = View.GONE
|
||||
b.tvCurrencyWarning.visibility = View.GONE
|
||||
if (qr != null && selectedCard == null) {
|
||||
val lastCard = t.messages.lastOrNull { m -> cardAccounts.any { it.accountNumber == m.accountNumber } }?.accountNumber
|
||||
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
|
||||
selectedCard = cardAccounts.firstOrNull { it.accountNumber == lastCard }
|
||||
?: cardAccounts.firstOrNull { it.accountNumber == defaultCard }
|
||||
?: cardAccounts.firstOrNull()
|
||||
}
|
||||
bindFromChip()
|
||||
}
|
||||
}
|
||||
|
||||
/** Saved static QR: pay it in the sheet over the chat. Otherwise: open the scanner. */
|
||||
private fun payBusiness() {
|
||||
val qr = merchantQr
|
||||
if (qr == null) {
|
||||
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment.newInstanceWithAutoScan())
|
||||
return
|
||||
}
|
||||
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||
val transfer = TransferFragment.newInstanceFromBmlQr(qr, selectedCard?.accountNumber, returnOnSuccess = true)
|
||||
payStartedAt = System.currentTimeMillis()
|
||||
hideKeyboard()
|
||||
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||
}
|
||||
|
||||
/** This month with this person (sent / received / net) or at this business (spent / payments). */
|
||||
private fun bindSummary(t: ChatThread) {
|
||||
val b = _binding ?: return
|
||||
val monthStart = java.util.Calendar.getInstance().apply {
|
||||
set(java.util.Calendar.DAY_OF_MONTH, 1)
|
||||
set(java.util.Calendar.HOUR_OF_DAY, 0); set(java.util.Calendar.MINUTE, 0)
|
||||
set(java.util.Calendar.SECOND, 0); set(java.util.Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
val thisMonth = t.messages.filter { it.timeMillis >= monthStart }
|
||||
if (thisMonth.isEmpty()) { b.summaryCard.visibility = View.GONE; return }
|
||||
// One currency per card: the selected account's, else the one used most this month.
|
||||
val currency = selectedTo?.currency?.takeIf { c -> thisMonth.any { it.currency == c } }
|
||||
?: thisMonth.groupingBy { it.currency }.eachCount().maxByOrNull { it.value }!!.key
|
||||
val ms = thisMonth.filter { it.currency == currency }
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
fun money(v: Double) = if (hide) "$currency ••••" else "$currency ${"%,.2f".format(v)}"
|
||||
val month = java.text.SimpleDateFormat("MMMM", java.util.Locale.getDefault()).format(java.util.Date())
|
||||
|
||||
b.summaryCard.visibility = View.VISIBLE
|
||||
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
|
||||
if (t.isMerchant) {
|
||||
b.tvSumLabel1.text = getString(R.string.chat_sum_spent, month)
|
||||
b.tvSumValue1.text = money(sent)
|
||||
b.sumColumn2.visibility = View.GONE
|
||||
b.tvSumLabel3.text = getString(R.string.chat_sum_payments)
|
||||
b.tvSumValue3.text = ms.size.toString()
|
||||
return
|
||||
}
|
||||
val received = ms.filter { !it.isSent }.sumOf { it.amount }
|
||||
val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
b.sumColumn2.visibility = View.VISIBLE
|
||||
b.tvSumLabel1.text = getString(R.string.chat_sum_sent, month)
|
||||
b.tvSumValue1.text = money(sent)
|
||||
b.tvSumValue1.setTextColor(MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorError))
|
||||
b.tvSumLabel2.text = getString(R.string.chat_sum_received)
|
||||
b.tvSumValue2.text = money(received)
|
||||
b.tvSumValue2.setTextColor(android.graphics.Color.parseColor(if (night) "#81C784" else "#2E7D32"))
|
||||
b.tvSumLabel3.text = getString(R.string.chat_sum_net)
|
||||
val net = received - sent
|
||||
b.tvSumValue3.text = if (hide) "••••" else (if (net < 0) "−" else "+") + "%,.2f".format(kotlin.math.abs(net))
|
||||
}
|
||||
|
||||
/** Long-press on a bubble: send the same again, open its receipt, or copy its reference. */
|
||||
private fun showMessageMenu(m: sh.sar.basedbank.util.ChatMessage, anchor: View) {
|
||||
val t = thread ?: return
|
||||
val menu = android.widget.PopupMenu(requireContext(), anchor, android.view.Gravity.END)
|
||||
val amount = "%.2f".format(kotlin.math.abs(m.amount))
|
||||
val canSendAgain = m.isSent && !t.isMerchant && selectedTo != null
|
||||
if (canSendAgain) menu.menu.add(0, 1, 0, getString(R.string.chat_send_again, "${m.currency} $amount"))
|
||||
if (m.receiptKey.isNotBlank()) menu.menu.add(0, 2, 1, R.string.chat_view_receipt_full)
|
||||
if (m.reference.isNotBlank()) menu.menu.add(0, 3, 2, R.string.chat_copy_reference)
|
||||
if (menu.menu.size() == 0) return
|
||||
menu.setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
1 -> {
|
||||
// Same account it went to, when that account is still one of theirs.
|
||||
t.accounts.firstOrNull { it.account == m.peerAccount }?.let { selectTo(it, matchFromCurrency = true) }
|
||||
binding.etAmount.setText(amount)
|
||||
binding.etNote.setText(m.note)
|
||||
send()
|
||||
}
|
||||
2 -> openReceipt(m.receiptKey)
|
||||
3 -> {
|
||||
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
|
||||
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("reference", m.reference))
|
||||
Snackbar.make(binding.root, R.string.chat_reference_copied, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
menu.show()
|
||||
}
|
||||
|
||||
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
|
||||
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
|
||||
val display = AccountListParser.from(account)
|
||||
row.tvDropdownAccountName.text = account.accountBriefName
|
||||
row.tvDropdownAccountNumber.text = account.accountNumber
|
||||
val balance = display?.balance.orEmpty()
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
|
||||
val type = display?.typeLabel.orEmpty()
|
||||
row.tvDropdownAccountType.text = type
|
||||
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
|
||||
row.ivDropdownCardLogo.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun showAccountPicker() {
|
||||
if (fromAccounts.isEmpty()) return
|
||||
val ctx = requireContext()
|
||||
val dialog = BottomSheetDialog(ctx)
|
||||
val list = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||
setPadding(0, pad, 0, pad * 3)
|
||||
}
|
||||
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
|
||||
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||
for (account in fromAccounts) {
|
||||
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||
bindAccountRow(row, account)
|
||||
// MVR can't go into a USD account: show those accounts, but not selectable.
|
||||
val blocked = isBlockedPair(account.currencyName, selectedTo?.currency.orEmpty())
|
||||
if (blocked) {
|
||||
row.tvDropdownAccountType.text = getString(R.string.chat_cant_send_mvr_to_usd)
|
||||
row.tvDropdownAccountType.visibility = View.VISIBLE
|
||||
row.root.alpha = 0.4f
|
||||
}
|
||||
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||
if (!blocked) row.root.setOnClickListener {
|
||||
selectFrom(account)
|
||||
dialog.dismiss()
|
||||
}
|
||||
list.addView(row.root)
|
||||
}
|
||||
dialog.setContentView(list)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun send() {
|
||||
val t = thread ?: return
|
||||
val from = selectedFrom ?: return
|
||||
val to = selectedTo ?: return
|
||||
if (isBlockedPair(from.currencyName, to.currency)) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0.0) {
|
||||
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
|
||||
return
|
||||
}
|
||||
binding.tilAmount.error = null
|
||||
val transfer = TransferFragment.newInstanceFromQr(
|
||||
accountNumber = to.account,
|
||||
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
|
||||
amount = "%.2f".format(amount),
|
||||
remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
|
||||
fromAccountNumber = from.accountNumber,
|
||||
returnOnSuccess = true,
|
||||
autoConfirm = true
|
||||
)
|
||||
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||
payStartedAt = System.currentTimeMillis()
|
||||
hideKeyboard()
|
||||
if (isBusinessProfile(from)) {
|
||||
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
|
||||
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||
} else {
|
||||
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
|
||||
// confirm dialog appears over the chat. A new send replaces any earlier one.
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
|
||||
private fun isBusinessProfile(account: BankAccount): Boolean {
|
||||
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
|
||||
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||
}
|
||||
|
||||
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
|
||||
private fun hideKeyboard() {
|
||||
val b = _binding ?: return
|
||||
val focused = b.root.findFocus() ?: return
|
||||
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
|
||||
?.hideSoftInputFromWindow(focused.windowToken, 0)
|
||||
focused.clearFocus()
|
||||
}
|
||||
|
||||
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
|
||||
private fun updateToolbar() {
|
||||
val t = thread ?: return
|
||||
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
|
||||
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
|
||||
header = h
|
||||
h.root.setOnClickListener { openContact() }
|
||||
}
|
||||
h.tvHeaderName.text = t.peerName
|
||||
// Real name (when titled with a nickname), plus the account and currency when there's only one.
|
||||
val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
|
||||
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
|
||||
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
|
||||
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
|
||||
).joinToString(" · ")
|
||||
h.tvHeaderAccount.text = subtitle
|
||||
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
|
||||
val sizePx = (40 * resources.displayMetrics.density).toInt()
|
||||
if (t.isMerchant) {
|
||||
h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
|
||||
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
|
||||
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
|
||||
} else {
|
||||
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
|
||||
}
|
||||
|
||||
requireActivity().title = t.peerName
|
||||
bar.setDisplayShowTitleEnabled(false)
|
||||
bar.setDisplayShowCustomEnabled(true)
|
||||
bar.customView = h.root
|
||||
}
|
||||
|
||||
private fun openContact() {
|
||||
val t = thread ?: return
|
||||
if (t.isMerchant) return
|
||||
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
|
||||
if (display != null) {
|
||||
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
|
||||
} else {
|
||||
// Not a saved contact yet: offer to save them, prefilled with what we know.
|
||||
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
|
||||
AddContactSheetFragment.newInstance(
|
||||
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
|
||||
accountNumber = selectedTo?.account,
|
||||
recipientName = t.realName.ifBlank { t.peerName },
|
||||
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
|
||||
).show(childFragmentManager, "add_contact")
|
||||
}
|
||||
}
|
||||
|
||||
override fun openTransfer(contact: ContactDisplay) {
|
||||
val fragment = TransferFragment.newInstance(
|
||||
accountNumber = contact.accountNumber,
|
||||
displayName = contact.name,
|
||||
subtitle = contact.transferSubtitle,
|
||||
colorHex = contact.bankColor,
|
||||
imageHash = contact.imageHash
|
||||
)
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
}
|
||||
|
||||
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
|
||||
override fun confirmDelete(contact: ContactDisplay) {
|
||||
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
updateToolbar()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? AppCompatActivity)?.supportActionBar?.apply {
|
||||
setDisplayShowCustomEnabled(false)
|
||||
setDisplayShowTitleEnabled(true)
|
||||
customView = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
header = null
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_PEER_KEY = "peer_key"
|
||||
private const val HISTORY_CATCH_UP_MS = 10_000L
|
||||
private const val TAG_HIDDEN_TRANSFER = "transfer_hidden"
|
||||
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||
private val CARD_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
|
||||
|
||||
fun newInstance(peerKey: String) = ChatFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.format.DateUtils
|
||||
import android.text.style.StyleSpan
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.graphics.ColorUtils
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
|
||||
import sh.sar.basedbank.databinding.ItemChatDateBinding
|
||||
import sh.sar.basedbank.util.ChatMessage
|
||||
|
||||
/**
|
||||
* One chat's transfers or payments as compact bubbles — sent / paid on the right, received on the
|
||||
* left — with ✓ (seen in a BML alert) or ✓✓ (booked in history). Day separators carry the day's
|
||||
* totals.
|
||||
*/
|
||||
class ChatMessagesAdapter(
|
||||
private val onReceiptClick: (ChatMessage) -> Unit,
|
||||
private val onLongPress: (ChatMessage, View) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
/** Rows of the list: a day separator or a bubble. */
|
||||
sealed class Item {
|
||||
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
|
||||
data class Bubble(val message: ChatMessage) : Item()
|
||||
}
|
||||
|
||||
private val items = mutableListOf<Item>()
|
||||
private var hideAmounts = false
|
||||
private var todayLabel = ""
|
||||
private var yesterdayLabel = ""
|
||||
|
||||
/** When the person has several accounts, sent bubbles say which one the money went to. */
|
||||
var showDestination = false
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
/** [messages] must be oldest first. */
|
||||
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
|
||||
this.todayLabel = todayLabel
|
||||
this.yesterdayLabel = yesterdayLabel
|
||||
items.clear()
|
||||
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
|
||||
items.add(Item.DateChip(day, dayMessages))
|
||||
dayMessages.forEach { items.add(Item.Bubble(it)) }
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||
is Item.DateChip -> TYPE_DATE
|
||||
is Item.Bubble -> TYPE_BUBBLE
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
|
||||
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is Item.DateChip -> (holder as DateVH).bind(item)
|
||||
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatAmount(m: ChatMessage) =
|
||||
if (hideAmounts) "${m.currency} ••••••" else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
|
||||
|
||||
private fun isNight(view: View) =
|
||||
(view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
private fun ticksFor(m: ChatMessage) = if (m.isBooked) "✓✓" else "✓"
|
||||
|
||||
private fun tickColor(view: View, m: ChatMessage, fallback: Int) =
|
||||
if (m.isBooked) Color.parseColor(if (isNight(view)) "#90CAF9" else "#1565C0") else fallback
|
||||
|
||||
inner class DateVH(private val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(item: Item.DateChip) {
|
||||
val first = item.messages.first()
|
||||
val label = when {
|
||||
DateUtils.isToday(first.timeMillis) -> todayLabel
|
||||
DateUtils.isToday(first.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
|
||||
else -> AccountHistoryAdapter.formatDateHeader(first.date)
|
||||
}
|
||||
val text = SpannableStringBuilder(label)
|
||||
text.setSpan(StyleSpan(Typeface.BOLD), 0, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
if (!hideAmounts) {
|
||||
// Day totals per currency: ↑ sent, ↓ received.
|
||||
val parts = item.messages.groupBy { it.currency }.map { (ccy, ms) ->
|
||||
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
|
||||
val received = ms.filter { !it.isSent }.sumOf { it.amount }
|
||||
listOfNotNull(
|
||||
"↑ %.2f".format(sent).takeIf { sent > 0 },
|
||||
"↓ %.2f".format(received).takeIf { received > 0 }
|
||||
).joinToString(" · ").let { if (item.messages.map { m -> m.currency }.toSet().size > 1) "$ccy $it" else it }
|
||||
}.filter { it.isNotBlank() }
|
||||
if (parts.isNotEmpty()) text.append(" ").append(parts.joinToString(" "))
|
||||
}
|
||||
b.tvDate.text = text
|
||||
}
|
||||
}
|
||||
|
||||
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(m: ChatMessage) {
|
||||
val ctx = b.root.context
|
||||
val sent = m.isSent
|
||||
val night = isNight(b.root)
|
||||
val density = ctx.resources.displayMetrics.density
|
||||
|
||||
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
|
||||
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||
|
||||
// Rounded, with a small "tail" corner on the sender's side at the bottom.
|
||||
val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||
val big = 18 * density
|
||||
val tail = 4 * density
|
||||
val tailOnRight = sent != rtl
|
||||
b.cardBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
.setTopLeftCornerSize(big).setTopRightCornerSize(big)
|
||||
.setBottomLeftCornerSize(if (tailOnRight) big else tail)
|
||||
.setBottomRightCornerSize(if (tailOnRight) tail else big)
|
||||
.build()
|
||||
|
||||
// Sent: tinted. Received: neutral with an outline, so the two read apart on any theme.
|
||||
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
|
||||
else Color.parseColor(if (night) "#2A2C2E" else "#FFFFFF")
|
||||
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
|
||||
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
|
||||
val green = Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||
b.cardBubble.setCardBackgroundColor(bg)
|
||||
b.cardBubble.strokeWidth = if (sent) 0 else density.toInt()
|
||||
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
|
||||
|
||||
b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
|
||||
b.tvAmount.setTextColor(if (sent) fg else green)
|
||||
b.tvNote.text = m.note
|
||||
b.tvNote.setTextColor(fg)
|
||||
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
|
||||
|
||||
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||
b.btnReceipt.setColorFilter(fg)
|
||||
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
|
||||
|
||||
b.tvAccount.text = when {
|
||||
!sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
|
||||
showDestination && m.peerAccount.isNotBlank() ->
|
||||
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
|
||||
else -> m.accountDisplayName
|
||||
}
|
||||
b.tvAccount.setTextColor(fg)
|
||||
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
|
||||
b.tvTime.setTextColor(fg)
|
||||
b.tvTicks.text = ticksFor(m)
|
||||
b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
|
||||
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
|
||||
|
||||
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
|
||||
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDetail(anchor: View, m: ChatMessage) {
|
||||
val ctx = anchor.context
|
||||
val details = buildString {
|
||||
val sign = if (m.isSent) "-" else "+"
|
||||
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
|
||||
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
|
||||
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
|
||||
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
|
||||
append("Account\n${m.accountDisplayName}\n\n")
|
||||
append("Status\n${ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)}")
|
||||
}
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(m.peerName)
|
||||
.setMessage(details)
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_DATE = 0
|
||||
private const val TYPE_BUBBLE = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.text.format.DateUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.shape.ShapeAppearanceModel
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.ItemChatBinding
|
||||
import sh.sar.basedbank.util.ChatThread
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
|
||||
/**
|
||||
* Telegram-style chat list: one row per person or business, latest transfer as the preview.
|
||||
* Pinned chats come first; long-press selects a chat (for pinning).
|
||||
*/
|
||||
class ChatsAdapter(
|
||||
private val onChatClick: (ChatThread) -> Unit,
|
||||
private val onChatLongPress: (ChatThread) -> Unit
|
||||
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
|
||||
|
||||
private var allThreads: List<ChatThread> = emptyList()
|
||||
private var displayed: List<ChatThread> = emptyList()
|
||||
private var pinned: List<String> = emptyList()
|
||||
private var searchQuery = ""
|
||||
private var hideAmounts = false
|
||||
|
||||
/** Chat key shown as selected (long-pressed), or null. */
|
||||
var selectedKey: String? = null
|
||||
set(value) { field = value; notifyDataSetChanged() }
|
||||
|
||||
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
|
||||
allThreads = threads
|
||||
pinned = pinnedKeys
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
fun setSearch(query: String) {
|
||||
searchQuery = query
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun isPinned(key: String) = key in pinned
|
||||
|
||||
val isEmpty get() = displayed.isEmpty()
|
||||
|
||||
private fun applyFilter() {
|
||||
val matching = if (searchQuery.isBlank()) allThreads else allThreads.filter {
|
||||
it.peerName.contains(searchQuery, ignoreCase = true) ||
|
||||
it.realName.contains(searchQuery, ignoreCase = true) ||
|
||||
it.peerAccount.contains(searchQuery) ||
|
||||
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
|
||||
}
|
||||
// Pinned first, most recently pinned on top; the rest stay newest first.
|
||||
val (pinnedThreads, others) = matching.partition { it.peerKey in pinned }
|
||||
displayed = pinnedThreads.sortedBy { pinned.indexOf(it.peerKey) } + others
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
holder.binding.root.setOnClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
|
||||
}
|
||||
holder.binding.root.setOnLongClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
|
||||
pos != RecyclerView.NO_POSITION
|
||||
}
|
||||
return holder
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
|
||||
|
||||
override fun getItemCount() = displayed.size
|
||||
|
||||
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(thread: ChatThread) {
|
||||
val ctx = binding.root.context
|
||||
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
val last = thread.last
|
||||
binding.tvName.text = thread.peerName
|
||||
|
||||
val amount = if (hideAmounts) "${last.currency} ••••••"
|
||||
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
|
||||
val ticks = if (last.isBooked) " ✓✓" else " ✓"
|
||||
binding.tvPreview.text = when {
|
||||
last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
|
||||
last.isSent -> {
|
||||
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
|
||||
ctx.getString(R.string.chat_preview_sent, amount) + note + ticks
|
||||
}
|
||||
else -> ctx.getString(R.string.chat_preview_received, amount)
|
||||
}
|
||||
binding.tvPreview.setTextColor(
|
||||
if (!last.isSent) Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||
else MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||
)
|
||||
|
||||
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
|
||||
else AccountHistoryAdapter.formatDateOnly(last.date)
|
||||
|
||||
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
|
||||
if (thread.isMerchant) {
|
||||
// Businesses get a rounded-square shop icon; people keep round initials or a photo.
|
||||
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||
.setAllCornerSizes(14 * ctx.resources.displayMetrics.density).build()
|
||||
binding.ivAvatar.setImageBitmap(shopBitmap(binding.root, merchantColor(thread.peerKey), sizePx))
|
||||
} else {
|
||||
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(sizePx / 2f).build()
|
||||
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
|
||||
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
|
||||
}
|
||||
|
||||
val isPinned = thread.peerKey in pinned
|
||||
binding.ivPin.visibility = if (isPinned) View.VISIBLE else View.GONE
|
||||
// Selected (long-pressed): tinted; pinned: a soft surface; otherwise the normal ripple.
|
||||
when {
|
||||
thread.peerKey == selectedKey -> binding.root.setBackgroundColor(
|
||||
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorErrorContainer))
|
||||
isPinned -> binding.root.setBackgroundColor(
|
||||
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorSurfaceContainerHigh))
|
||||
else -> binding.root.setBackgroundResource(selectableBackground(binding.root))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectableBackground(view: View): Int {
|
||||
val out = android.util.TypedValue()
|
||||
view.context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true)
|
||||
return out.resourceId
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
|
||||
private val MERCHANT_COLORS = listOf("#6D4C41", "#546E7A", "#5D4037", "#455A64", "#8D6E63", "#37474F")
|
||||
|
||||
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
|
||||
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
|
||||
|
||||
fun merchantColor(peerKey: String) = MERCHANT_COLORS[(peerKey.hashCode() and 0x7fffffff) % MERCHANT_COLORS.size]
|
||||
|
||||
/** Shop icon on a coloured square; the image view's shape rounds the corners. */
|
||||
fun shopBitmap(view: View, colorHex: String, sizePx: Int): Bitmap {
|
||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
canvas.drawRect(RectF(0f, 0f, sizePx.toFloat(), sizePx.toFloat()),
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(colorHex) })
|
||||
val icon = ContextCompat.getDrawable(view.context, R.drawable.ic_store)?.mutate() ?: return bm
|
||||
val inset = sizePx / 4
|
||||
icon.setBounds(inset, inset, sizePx - inset, sizePx - inset)
|
||||
icon.draw(canvas)
|
||||
return bm
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentChatsBinding
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.ChatStore
|
||||
import sh.sar.basedbank.util.ChatThread
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
/**
|
||||
* Transfers grouped by person or business, shown as a chat list. Contacts is reachable from the
|
||||
* toolbar; long-press a chat to pin it to the top.
|
||||
*/
|
||||
class ChatsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentChatsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private val app get() = requireActivity().application as BasedBankApp
|
||||
|
||||
private lateinit var adapter: ChatsAdapter
|
||||
|
||||
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
onQrScanned(raw)
|
||||
}
|
||||
private var actionMode: ActionMode? = null
|
||||
private var syncedThisView = false
|
||||
private var syncedWithContacts = false
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentChatsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = ChatsAdapter(
|
||||
onChatClick = { thread ->
|
||||
if (actionMode != null) actionMode?.finish()
|
||||
else (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
|
||||
},
|
||||
onChatLongPress = { thread -> startSelection(thread) }
|
||||
)
|
||||
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.rvChats.adapter = adapter
|
||||
|
||||
binding.etSearch.addTextChangedListener { text ->
|
||||
adapter.setSearch(text?.toString() ?: "")
|
||||
updateEmptyView()
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener { sync() }
|
||||
|
||||
// Scan to Pay: opens the QR scanner; after paying it comes back here and refreshes.
|
||||
binding.fabScanPay.setOnClickListener {
|
||||
scanLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||
sync()
|
||||
// BML's alert often lands a few seconds after the payment: check again shortly.
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
delay(RECHECK_AFTER_PAYMENT_MS)
|
||||
if (_binding != null) sync()
|
||||
}
|
||||
}
|
||||
// Keep the button above the system navigation bar when there's no bottom bar.
|
||||
val fabMargin = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.fabScanPay) { v, insets ->
|
||||
val bottomNav = NavCustomization.getNavMode(
|
||||
requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)
|
||||
) == NavCustomization.NAV_MODE_BOTTOM
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||
(v.layoutParams as android.widget.FrameLayout.LayoutParams).bottomMargin =
|
||||
fabMargin + if (bottomNav) 0 else navBar
|
||||
v.requestLayout()
|
||||
insets
|
||||
}
|
||||
|
||||
requireActivity().addMenuProvider(object : MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
|
||||
.setIcon(R.drawable.ic_contacts)
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
if (menuItem.itemId != R.id.action_open_contacts) return false
|
||||
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||
return true
|
||||
}
|
||||
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
// Contacts give chats a saved name and an account number to send to.
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||
loadThreads()
|
||||
// Contacts usually load after the first sync; sync again so their real names get looked up.
|
||||
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
|
||||
}
|
||||
|
||||
// Accounts arrive after login; sync once they are available.
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
|
||||
}
|
||||
|
||||
loadThreads()
|
||||
}
|
||||
|
||||
private fun sync() {
|
||||
syncedThisView = true
|
||||
binding.swipeRefresh.isRefreshing = true
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
if (contacts.isNotEmpty()) syncedWithContacts = true
|
||||
val ctx = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||
_binding?.swipeRefresh?.isRefreshing = false
|
||||
loadThreads()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadThreads() {
|
||||
val ctx = requireContext().applicationContext
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val (threads, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
|
||||
if (_binding == null) return@launch
|
||||
adapter.updateThreads(threads, pins)
|
||||
updateEmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A scanned QR: BML Scan to Pay (static or card-machine) opens the payment, a PayMV QR opens a
|
||||
* transfer. Either way, success comes back to this list, which then refreshes.
|
||||
*/
|
||||
private fun onQrScanned(raw: String) {
|
||||
val activity = requireActivity() as HomeActivity
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
activity.showWithBackStack(TransferFragment.newInstanceFromBmlQr(bmlTarget, null, returnOnSuccess = true))
|
||||
return
|
||||
}
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
val account = qr?.accountNumber
|
||||
if (account == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
activity.showWithBackStack(TransferFragment.newInstanceFromQr(
|
||||
accountNumber = account,
|
||||
displayName = qr.merchantName ?: account,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose,
|
||||
returnOnSuccess = true
|
||||
))
|
||||
}
|
||||
|
||||
/** Long-press: select the chat and offer Pin / Unpin in the top bar. */
|
||||
private fun startSelection(thread: ChatThread) {
|
||||
adapter.selectedKey = thread.peerKey
|
||||
val pinned = adapter.isPinned(thread.peerKey)
|
||||
val callback = object : ActionMode.Callback {
|
||||
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||
mode.title = getString(R.string.chat_selected_one)
|
||||
menu.add(Menu.NONE, R.id.action_pin_chat, 0, if (pinned) R.string.chat_unpin else R.string.chat_pin)
|
||||
.setIcon(R.drawable.ic_pin)
|
||||
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
|
||||
return true
|
||||
}
|
||||
override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
|
||||
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||
if (item.itemId != R.id.action_pin_chat) return false
|
||||
setPinned(thread, !pinned, offerUndo = true)
|
||||
mode.finish()
|
||||
return true
|
||||
}
|
||||
override fun onDestroyActionMode(mode: ActionMode) {
|
||||
actionMode = null
|
||||
adapter.selectedKey = null
|
||||
}
|
||||
}
|
||||
actionMode?.finish()
|
||||
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
|
||||
}
|
||||
|
||||
private fun setPinned(thread: ChatThread, pin: Boolean, offerUndo: Boolean) {
|
||||
val ctx = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) { ChatStore.setPinned(ctx, thread.peerKey, pin) }
|
||||
loadThreads()
|
||||
val b = _binding ?: return@launch
|
||||
if (!offerUndo) return@launch
|
||||
Snackbar.make(b.root, getString(if (pin) R.string.chat_pinned_msg else R.string.chat_unpinned_msg, thread.peerName), Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.chat_undo) { setPinned(thread, !pin, offerUndo = false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateEmptyView() {
|
||||
val b = _binding ?: return
|
||||
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
|
||||
if (!adapter.isEmpty) return
|
||||
val since = ChatStore.sinceMillis(requireContext())
|
||||
b.emptyView.text = when {
|
||||
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
|
||||
since > 0L -> getString(R.string.chats_empty_since,
|
||||
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
|
||||
else -> getString(R.string.chats_empty)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_chats)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
actionMode?.finish()
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
|
||||
}
|
||||
}
|
||||
@@ -24,14 +24,14 @@ import sh.sar.basedbank.util.ContactListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
|
||||
/** Contact details drawer shown when a contact is tapped in [ContactsFragment] or a chat header. */
|
||||
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private var _binding: SheetContactDetailsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private val contactsFragment get() = parentFragment as? ContactsFragment
|
||||
private val contactsFragment get() = parentFragment as? ContactSheetHost
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
|
||||
/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */
|
||||
interface ContactSheetHost {
|
||||
fun openTransfer(contact: ContactDisplay)
|
||||
fun confirmDelete(contact: ContactDisplay)
|
||||
}
|
||||
@@ -40,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
class ContactsFragment : Fragment() {
|
||||
class ContactsFragment : Fragment(), ContactSheetHost {
|
||||
|
||||
private var _binding: FragmentContactsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
@@ -182,7 +182,7 @@ class ContactsFragment : Fragment() {
|
||||
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||
}
|
||||
|
||||
internal fun openTransfer(contact: ContactDisplay) {
|
||||
override fun openTransfer(contact: ContactDisplay) {
|
||||
val fragment = TransferFragment.newInstance(
|
||||
accountNumber = contact.accountNumber,
|
||||
displayName = contact.name,
|
||||
@@ -193,7 +193,7 @@ class ContactsFragment : Fragment() {
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
}
|
||||
|
||||
internal fun confirmDelete(contact: ContactDisplay) {
|
||||
override fun confirmDelete(contact: ContactDisplay) {
|
||||
val ctx = requireContext()
|
||||
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
||||
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||
|
||||
@@ -176,6 +176,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
val frag = when (item.itemId) {
|
||||
R.id.nav_dashboard -> DashboardFragment()
|
||||
R.id.nav_accounts -> AccountsFragment()
|
||||
R.id.nav_chats -> ChatsFragment()
|
||||
R.id.nav_contacts -> ContactsFragment()
|
||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||
@@ -473,6 +474,7 @@ fun applyNavLabelVisibility() {
|
||||
val dest = fragment ?: when (itemId) {
|
||||
R.id.nav_dashboard -> DashboardFragment()
|
||||
R.id.nav_accounts -> AccountsFragment()
|
||||
R.id.nav_chats -> ChatsFragment()
|
||||
R.id.nav_contacts -> ContactsFragment()
|
||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||
@@ -528,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()))
|
||||
|
||||
@@ -32,6 +32,7 @@ object NavCustomization {
|
||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||
val ALL_SWAPPABLE = listOf(
|
||||
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||
NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats),
|
||||
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||
@@ -51,14 +52,14 @@ object NavCustomization {
|
||||
|
||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
||||
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
|
||||
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats),
|
||||
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
||||
)
|
||||
|
||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||
prefs.edit()
|
||||
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
||||
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
|
||||
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats")
|
||||
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
||||
.apply()
|
||||
}
|
||||
@@ -79,7 +80,7 @@ object NavCustomization {
|
||||
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
|
||||
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
|
||||
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
|
||||
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_chats),
|
||||
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
|
||||
)
|
||||
|
||||
@@ -87,7 +88,7 @@ object NavCustomization {
|
||||
prefs.edit()
|
||||
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
|
||||
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
|
||||
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
|
||||
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_chats")
|
||||
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
|
||||
.apply()
|
||||
}
|
||||
|
||||
@@ -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<OtpEntry>()
|
||||
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<OtpEntry>) :
|
||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -117,7 +118,7 @@ class TransferFragment : Fragment() {
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||
showReceipt(receipt, avatar)
|
||||
}
|
||||
).also { bmlHandler = it }
|
||||
|
||||
@@ -157,7 +158,7 @@ class TransferFragment : Fragment() {
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||
showReceipt(receipt, avatar)
|
||||
}
|
||||
).also { mfaisaHandler = it }
|
||||
|
||||
@@ -177,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
|
||||
}
|
||||
|
||||
@@ -247,18 +252,30 @@ 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"
|
||||
|
||||
/** Fragment result sent instead of opening the receipt when [ARG_RETURN_ON_SUCCESS] is set. */
|
||||
const val RESULT_TRANSFER_DONE = "transfer_done"
|
||||
|
||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||
}
|
||||
|
||||
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
|
||||
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null, returnOnSuccess: Boolean = false) = TransferFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_BML_QR_URL, qrUrl)
|
||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) }
|
||||
}
|
||||
@@ -284,7 +301,9 @@ class TransferFragment : Fragment() {
|
||||
displayName: String,
|
||||
amount: String?,
|
||||
remarks: String?,
|
||||
fromAccountNumber: String? = null
|
||||
fromAccountNumber: String? = null,
|
||||
returnOnSuccess: Boolean = false,
|
||||
autoConfirm: Boolean = false
|
||||
) = TransferFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT, accountNumber)
|
||||
@@ -294,10 +313,35 @@ class TransferFragment : Fragment() {
|
||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||
if (returnOnSuccess) putBoolean(ARG_RETURN_ON_SUCCESS, true)
|
||||
if (autoConfirm) putBoolean(ARG_AUTO_CONFIRM, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the receipt, or — when opened with returnOnSuccess (e.g. from a chat) — goes back to
|
||||
* the caller and tells it via [RESULT_TRANSFER_DONE] on the activity's fragment manager;
|
||||
* the receipt is already in ReceiptStore.
|
||||
*/
|
||||
private fun showReceipt(receipt: TransferReceiptData, avatar: Bitmap?) {
|
||||
if (!returnToCallerIfRequested()) {
|
||||
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When opened with returnOnSuccess, tells the caller the payment went through and goes back to
|
||||
* it — closing the sheet this page is hosted in, if any. Returns false when not requested.
|
||||
*/
|
||||
internal fun returnToCallerIfRequested(): Boolean {
|
||||
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) != true) return false
|
||||
requireActivity().supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
|
||||
val sheet = parentFragment as? androidx.fragment.app.DialogFragment
|
||||
if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentTransferBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -380,7 +424,22 @@ class TransferFragment : Fragment() {
|
||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||
|
||||
// Opened fully filled in (e.g. from a chat): go straight to the confirm dialog, once. Posted
|
||||
// so the source account, picked when the accounts arrive, is selected first.
|
||||
if (arguments?.getBoolean(ARG_AUTO_CONFIRM) == true) {
|
||||
view.post {
|
||||
if (_binding == null || arguments?.getBoolean(ARG_AUTO_CONFIRM) != true) return@post
|
||||
arguments?.remove(ARG_AUTO_CONFIRM)
|
||||
if (selectedAccount != null && resolvedAccountNumber.isNotBlank()) initiateTransfer()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -765,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())
|
||||
@@ -1209,7 +1301,7 @@ class TransferFragment : Fragment() {
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
dialog.dismiss()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToPhoto))
|
||||
showReceipt(receipt, capturedToPhoto)
|
||||
} else if (!ok) {
|
||||
dialog.dismiss()
|
||||
if (msg == "CONNECTIVITY") {
|
||||
|
||||
@@ -168,6 +168,15 @@ class TransferReceiptFragment : Fragment() {
|
||||
view.findViewById<MaterialButton>(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 ──────────────────────────────────────────────────────────
|
||||
@@ -483,11 +492,11 @@ class TransferReceiptFragment : Fragment() {
|
||||
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(); return }
|
||||
if (bank == "MIB") { showMibFullScreenReceipt(); return }
|
||||
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 {
|
||||
@@ -529,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 ->
|
||||
@@ -545,7 +555,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
* 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() {
|
||||
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||
@@ -557,6 +567,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
))
|
||||
|
||||
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
|
||||
@@ -594,7 +605,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
* (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() {
|
||||
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||
@@ -607,6 +618,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
))
|
||||
|
||||
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
|
||||
@@ -647,6 +659,12 @@ class TransferReceiptFragment : Fragment() {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import sh.sar.basedbank.databinding.SheetTransferBinding
|
||||
|
||||
/**
|
||||
* Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible.
|
||||
* The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet.
|
||||
*/
|
||||
class TransferSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private var _binding: SheetTransferBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = SheetTransferBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
// Leave the top of the chat visible above the sheet.
|
||||
binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt()
|
||||
if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) {
|
||||
val transferArgs = requireArguments()
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs })
|
||||
.commitNow()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
(dialog as? BottomSheetDialog)?.let { d ->
|
||||
// Keep the amount/remarks fields above the keyboard, but don't open it by itself.
|
||||
@Suppress("DEPRECATION")
|
||||
d.window?.setSoftInputMode(
|
||||
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
|
||||
)
|
||||
d.behavior.skipCollapsed = true
|
||||
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SHEET_HEIGHT_FRACTION = 0.8f
|
||||
|
||||
/** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */
|
||||
fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs }
|
||||
}
|
||||
}
|
||||
@@ -351,6 +351,7 @@ class BmlTransferHandler(
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
fragment.returnToCallerIfRequested()
|
||||
}
|
||||
} else {
|
||||
dialog.dismiss()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Encrypted directory of account number → real account-holder name and currency, looked up through BML's
|
||||
* account validation. Saved contacts often carry a nickname, while bank history only shows the
|
||||
* real name; this lets a name-only transfer be matched to the right account.
|
||||
*/
|
||||
object AccountNameStore {
|
||||
|
||||
private const val FILE_NAME = "account_names.json"
|
||||
private val lock = Any()
|
||||
|
||||
/** Names are re-checked after this long, in case an account changes hands. */
|
||||
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
|
||||
/** Failed lookups (closed or foreign accounts) are retried after this long. */
|
||||
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
|
||||
/*
|
||||
* BML's account validation is the same search the Transfer page uses, and BML rate-limits it
|
||||
* per user ("searched too many times"). Lookups are a background nicety, so they get a small
|
||||
* budget and back off at the first refusal, leaving the user's own searches alone.
|
||||
*/
|
||||
private const val MAX_LOOKUPS_PER_RUN = 3
|
||||
private const val MIN_RUN_INTERVAL_MS = 6L * 60 * 60 * 1000
|
||||
private const val MAX_LOOKUPS_PER_DAY = 15
|
||||
private const val BACKOFF_MS = 24L * 60 * 60 * 1000
|
||||
private const val BUDGET_PREFS = "chat_name_lookup_budget"
|
||||
|
||||
data class Info(val name: String, val currency: String)
|
||||
|
||||
private data class Entry(val name: String, val currency: String, val checkedAt: Long)
|
||||
|
||||
/** Account number → real name and currency, for every account resolved so far. */
|
||||
fun all(context: Context): Map<String, Info> = synchronized(lock) {
|
||||
load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up names for [accounts] (most important first) that are unknown or stale, within the
|
||||
* budget above. Network I/O — call on Dispatchers.IO.
|
||||
*/
|
||||
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
|
||||
val now = System.currentTimeMillis()
|
||||
val budget = context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE)
|
||||
if (now < budget.getLong("blockedUntil", 0L)) return
|
||||
if (now - budget.getLong("lastRunAt", 0L) < MIN_RUN_INTERVAL_MS) return
|
||||
val today = now / (24L * 60 * 60 * 1000)
|
||||
val usedToday = if (budget.getLong("day", -1L) == today) budget.getInt("count", 0) else 0
|
||||
val allowed = minOf(MAX_LOOKUPS_PER_RUN, MAX_LOOKUPS_PER_DAY - usedToday)
|
||||
if (allowed <= 0) return
|
||||
|
||||
val known = synchronized(lock) { load(context) }
|
||||
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
|
||||
val e = known[account] ?: return@filter true
|
||||
val age = now - e.checkedAt
|
||||
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
|
||||
}.take(allowed)
|
||||
if (due.isEmpty()) return
|
||||
|
||||
val client = BmlValidateClient()
|
||||
val found = mutableMapOf<String, Entry>()
|
||||
var used = 0
|
||||
for (account in due) {
|
||||
used++
|
||||
val v = try { client.validateAccount(session, account) } catch (_: Exception) { null }
|
||||
if (v == null || v.name.isBlank()) {
|
||||
// Refused, limited, or unknown: stop for the day rather than keep asking. The
|
||||
// account is marked failed so it's retried later, not first in line tomorrow.
|
||||
found[account] = Entry("", "", now)
|
||||
budget.edit().putLong("blockedUntil", now + BACKOFF_MS).apply()
|
||||
break
|
||||
}
|
||||
found[account] = Entry(v.name.trim(), v.currency, now)
|
||||
}
|
||||
budget.edit()
|
||||
.putLong("lastRunAt", now)
|
||||
.putLong("day", today)
|
||||
.putInt("count", usedToday + used)
|
||||
.apply()
|
||||
|
||||
synchronized(lock) {
|
||||
val current = load(context).toMutableMap()
|
||||
current.putAll(found)
|
||||
save(context, current)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearAll(context: Context) = synchronized(lock) {
|
||||
File(context.filesDir, FILE_NAME).delete()
|
||||
context.getSharedPreferences(BUDGET_PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
private fun load(context: Context): Map<String, Entry> {
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
if (!file.exists()) return emptyMap()
|
||||
return try {
|
||||
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||
root.keys().asSequence().associateWith { key ->
|
||||
val o = root.getJSONObject(key)
|
||||
Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L))
|
||||
}
|
||||
} catch (_: Exception) { emptyMap() }
|
||||
}
|
||||
|
||||
private fun save(context: Context, entries: Map<String, Entry>) {
|
||||
try {
|
||||
val root = JSONObject()
|
||||
entries.forEach { (account, e) ->
|
||||
root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt))
|
||||
}
|
||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import sh.sar.basedbank.ui.home.AccountHistoryAdapter
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* One transfer shown as a chat bubble. BML history only carries the counterparty's name, so
|
||||
* [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name.
|
||||
*/
|
||||
data class ChatMessage(
|
||||
val id: String,
|
||||
val peerKey: String,
|
||||
val peerName: String,
|
||||
val amount: Double, // negative = sent, positive = received
|
||||
val currency: String,
|
||||
val timeMillis: Long,
|
||||
val accountNumber: String, // own account the money moved in/out of
|
||||
val accountDisplayName: String,
|
||||
val reference: String = "",
|
||||
val note: String = "", // remarks — only known for transfers sent from this app
|
||||
val peerAccount: String = "", // only known for transfers sent from this app
|
||||
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
|
||||
val dateOnly: Boolean = false, // bank gave only a date (Favara); time of day unknown
|
||||
val merchant: Boolean = false // card / Scan to Pay payment to a business, not a person
|
||||
) {
|
||||
val isSent get() = amount < 0
|
||||
/** In BML's account history (✓✓); otherwise only seen in a notification or receipt so far (✓). */
|
||||
val isBooked get() = id.startsWith("bml_")
|
||||
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
|
||||
val date: String get() = DATE_FMT.format(Date(timeMillis))
|
||||
|
||||
companion object {
|
||||
private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
}
|
||||
}
|
||||
|
||||
/** One of a person's accounts that can be sent to from their chat. */
|
||||
data class ChatAccount(
|
||||
val account: String,
|
||||
val currency: String, // "MVR", "USD"; blank if not known yet
|
||||
val contact: BankContact? // saved contact for this account, if any
|
||||
)
|
||||
|
||||
data class ChatThread(
|
||||
val peerKey: String, // "name:<normalised real name>" or "shop:<normalised business name>"
|
||||
val peerName: String, // contact nickname, else the real name
|
||||
val realName: String, // account holder's name as the bank knows it; may be blank
|
||||
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
|
||||
val messages: List<ChatMessage>, // oldest first
|
||||
val isMerchant: Boolean = false // a business paid by card / Scan to Pay; nothing to send to
|
||||
) {
|
||||
val last get() = messages.last()
|
||||
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
|
||||
/** The account last sent to, else the first known one; blank when none is known. */
|
||||
val peerAccount: String get() =
|
||||
messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount
|
||||
?.takeIf { a -> accounts.any { it.account == a } }
|
||||
?: accounts.firstOrNull()?.account.orEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypted local record of BML transfers and card / Scan to Pay payments, shown as chats.
|
||||
*
|
||||
* Old history is never loaded: the first call to [sync] only stamps the start time, and every
|
||||
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
|
||||
* pages back through the latest history of every BML current/savings account (booked later), and
|
||||
* merges in receipts of transfers sent from this app, keeping one message per transfer.
|
||||
*/
|
||||
object ChatStore {
|
||||
|
||||
private const val FILE_NAME = "chats.json"
|
||||
private const val PINS_FILE_NAME = "chat_pins.json"
|
||||
private const val QR_FILE_NAME = "chat_merchant_qr.json"
|
||||
/** Prefix [RecentsCache] uses for static BML merchant QRs the user has paid. */
|
||||
private const val RECENT_QR_PREFIX = "bmlqr:"
|
||||
private val lock = Any()
|
||||
|
||||
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||
private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
|
||||
/** Card and Scan to Pay payments; narrative2 is the business name. */
|
||||
private val MERCHANT_TYPES = setOf("Purchase")
|
||||
|
||||
/** "You have received MVR 5.00 from NAME to 7730*****1234" */
|
||||
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
|
||||
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
|
||||
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
|
||||
/** "You have paid MVR 5.00 from 4xxx********xxxx to BUSINESS" (Scan to Pay / card); the name may be cut short. */
|
||||
private val NOTIF_PAID = Regex("""You have paid ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
|
||||
/** Masked own account or card in notifications, e.g. "7730*****1234". */
|
||||
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
|
||||
|
||||
/** How far apart two records of the same transfer can be timestamped. */
|
||||
private const val MATCH_WINDOW_MS = 15 * 60 * 1000L
|
||||
|
||||
/** Upper bound on history pages fetched per account in one sync. */
|
||||
private const val MAX_PAGES_PER_SYNC = 5
|
||||
|
||||
private const val NAME_KEY_PREFIX = "name:"
|
||||
private const val MERCHANT_KEY_PREFIX = "shop:"
|
||||
|
||||
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
|
||||
|
||||
fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US)
|
||||
|
||||
fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis }
|
||||
|
||||
fun isStarted(context: Context) = sinceMillis(context) > 0L
|
||||
|
||||
/**
|
||||
* Groups stored messages into chats, most recent chat first. Call off the main thread.
|
||||
*
|
||||
* Chats are per person, keyed by the account holder's real name: bank history only names the
|
||||
* sender, never their account, so money can't be told apart per account anyway. Every known
|
||||
* account with that real name (saved contacts, accounts sent to) belongs to the chat, and the
|
||||
* user picks which one to send to — typically their MVR or USD account.
|
||||
*
|
||||
* Real names come from [AccountNameStore], so a contact saved as "Ali" joins the
|
||||
* "Mohamed Ali" chat.
|
||||
*/
|
||||
fun threads(context: Context, contacts: List<BankContact>): List<ChatThread> {
|
||||
val messages = synchronized(lock) { load(context).messages }
|
||||
val lookedUp = AccountNameStore.all(context)
|
||||
val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount }
|
||||
|
||||
fun realNameOf(account: String): String? =
|
||||
lookedUp[account]?.name?.takeIf { it.isNotBlank() }
|
||||
?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() }
|
||||
|
||||
// Notifications cut business names short ("FAMILY ROOM" for "FAMILY ROOM COFFEE"); file a
|
||||
// short name under the longest known name it starts.
|
||||
val merchantNames = messages.filter { it.merchant }.map { it.peerKey }.toSet()
|
||||
fun merchantKey(name: String) =
|
||||
merchantNames.filter { it.startsWith(name) }.maxByOrNull { it.length } ?: name
|
||||
|
||||
fun keyOf(m: ChatMessage): String {
|
||||
if (m.merchant) return MERCHANT_KEY_PREFIX + merchantKey(m.peerKey)
|
||||
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
|
||||
return NAME_KEY_PREFIX + normalise(name)
|
||||
}
|
||||
|
||||
// Every known account, grouped under its holder's real name.
|
||||
val accountsByKey = HashMap<String, LinkedHashMap<String, ChatAccount>>()
|
||||
fun addAccount(account: String, fallbackCurrency: String) {
|
||||
val name = realNameOf(account) ?: return
|
||||
val contact = contactsByAccount[account]
|
||||
val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() }
|
||||
?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency
|
||||
accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() }
|
||||
.getOrPut(account) { ChatAccount(account, currency, contact) }
|
||||
}
|
||||
contactsByAccount.keys.forEach { addAccount(it, "") }
|
||||
messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) }
|
||||
|
||||
return messages.groupBy(::keyOf).map { (key, msgs) ->
|
||||
val sorted = msgs.sortedBy { it.timeMillis }
|
||||
if (key.startsWith(MERCHANT_KEY_PREFIX)) {
|
||||
// History carries the full business name; notifications may not.
|
||||
val name = sorted.lastOrNull { it.id.startsWith("bml_") }?.peerName
|
||||
?: sorted.maxByOrNull { it.peerName.length }!!.peerName
|
||||
return@map ChatThread(key, name, "", emptyList(), sorted, isMerchant = true)
|
||||
}
|
||||
val accounts = accountsByKey[key]?.values?.toList().orEmpty()
|
||||
// Bank history and the account lookup carry the real name; a receipt only has our label.
|
||||
val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
|
||||
?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName
|
||||
?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } }
|
||||
?: ""
|
||||
val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } }
|
||||
ChatThread(
|
||||
peerKey = key,
|
||||
peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName,
|
||||
realName = realName,
|
||||
accounts = accounts,
|
||||
messages = sorted
|
||||
)
|
||||
}.sortedByDescending { it.last.timeMillis }
|
||||
}
|
||||
|
||||
/** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */
|
||||
suspend fun sync(context: Context, app: BasedBankApp, accounts: List<BankAccount>, contacts: List<BankContact>) {
|
||||
val state = synchronized(lock) { load(context) }
|
||||
if (state.sinceMillis == 0L) {
|
||||
synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) }
|
||||
return
|
||||
}
|
||||
val since = state.sinceMillis
|
||||
|
||||
val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id })
|
||||
val notifications = fetchNotifications(context, app, accounts, since)
|
||||
val receipts = receiptMessages(context, accounts, since)
|
||||
|
||||
synchronized(lock) {
|
||||
val current = load(context)
|
||||
save(context, current.copy(messages = merge(current.messages, history + notifications + receipts)))
|
||||
}
|
||||
|
||||
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
|
||||
val session = app.anyBmlSession() ?: return
|
||||
// Only what the saved data can't answer, most useful first: accounts sent to that aren't
|
||||
// contacts, then contacts saved without a separate real name. SWIFT (foreign) beneficiaries
|
||||
// can't be looked up through BML.
|
||||
val contactAccounts = contacts.mapTo(HashSet()) { it.benefAccount }
|
||||
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
|
||||
.filter { it.isNotBlank() && it !in contactAccounts }
|
||||
val nameless = contacts.filter {
|
||||
it.benefType != "S" && it.benefAccount.isNotBlank() &&
|
||||
(it.benefName.isBlank() || normalise(it.benefName) == normalise(it.benefNickName))
|
||||
}.map { it.benefAccount }
|
||||
val toResolve = sentTo + nameless
|
||||
AccountNameStore.resolve(context, session, toResolve)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers from account history. BML only adds a transaction here once it is booked (often
|
||||
* the next day), so recent ones come from [fetchNotifications] first.
|
||||
*/
|
||||
private suspend fun fetchHistory(
|
||||
app: BasedBankApp,
|
||||
accounts: List<BankAccount>,
|
||||
since: Long,
|
||||
knownIds: Set<String>
|
||||
): List<ChatMessage> {
|
||||
val fetched = mutableListOf<ChatMessage>()
|
||||
for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) {
|
||||
val fetcher = HistoryFetcher(account)
|
||||
var pages = 0
|
||||
// Page back until we reach transfers we already have or ones older than the start time.
|
||||
while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) {
|
||||
val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break }
|
||||
pages++
|
||||
if (page.isEmpty()) break
|
||||
var reachedKnown = false
|
||||
for (trx in page) {
|
||||
val time = AccountHistoryAdapter.parseDateMillis(trx.date)
|
||||
if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true
|
||||
trx.toChatMessage(time, since)?.let { fetched += it }
|
||||
}
|
||||
if (reachedKnown) break
|
||||
}
|
||||
}
|
||||
return fetched
|
||||
}
|
||||
|
||||
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
|
||||
val name = counterpartyName?.trim().orEmpty()
|
||||
val merchant = description in MERCHANT_TYPES
|
||||
if ((description !in TRANSFER_TYPES && !merchant) || name.isBlank()) return null
|
||||
// Favara (other-bank) entries carry only a date; keep those from the start day onwards.
|
||||
val dateOnly = date.contains("T00:00:00")
|
||||
if (if (dateOnly) time < startOfDay(since) else time < since) return null
|
||||
return ChatMessage(
|
||||
id = "bml_$id",
|
||||
peerKey = normalise(name),
|
||||
peerName = name,
|
||||
amount = amount,
|
||||
currency = currency,
|
||||
timeMillis = time,
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
reference = reference.orEmpty(),
|
||||
dateOnly = dateOnly,
|
||||
merchant = merchant
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive
|
||||
* instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page
|
||||
* is not written back to the cache, which the notification poller uses to spot new alerts.
|
||||
*/
|
||||
private fun fetchNotifications(
|
||||
context: Context,
|
||||
app: BasedBankApp,
|
||||
accounts: List<BankAccount>,
|
||||
since: Long
|
||||
): List<ChatMessage> {
|
||||
val client = BmlNotificationsClient()
|
||||
val notifications = LinkedHashMap<String, AppNotification>()
|
||||
for ((loginId, session) in app.bmlSessions.toMap()) {
|
||||
NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it }
|
||||
try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } }
|
||||
catch (_: Exception) {}
|
||||
}
|
||||
return notifications.values
|
||||
.filter { it.timestampMs >= since }
|
||||
.mapNotNull { notificationToMessage(it, accounts) }
|
||||
}
|
||||
|
||||
private fun notificationToMessage(n: AppNotification, accounts: List<BankAccount>): ChatMessage? {
|
||||
val text = n.message.trim()
|
||||
val received = NOTIF_RECEIVED.matchEntire(text)
|
||||
val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null
|
||||
val paid = if (received == null && sent == null) NOTIF_PAID.matchEntire(text) else null
|
||||
val (currency, amountText, name, ownMasked) = when {
|
||||
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) }
|
||||
sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
|
||||
paid != null -> paid.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
|
||||
else -> return null
|
||||
}
|
||||
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
|
||||
val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null
|
||||
val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m ->
|
||||
val (head, tail) = m.destructured
|
||||
accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) }
|
||||
}
|
||||
return ChatMessage(
|
||||
id = "ntf_${n.id}",
|
||||
peerKey = normalise(name),
|
||||
peerName = name.trim(),
|
||||
amount = if (received != null) amount else -amount,
|
||||
currency = currency,
|
||||
timeMillis = n.timestampMs,
|
||||
accountNumber = own?.accountNumber.orEmpty(),
|
||||
accountDisplayName = own?.accountBriefName ?: "•••• ${ownMasked.takeLast(4)}",
|
||||
merchant = paid != null
|
||||
)
|
||||
}
|
||||
|
||||
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
|
||||
private fun receiptMessages(context: Context, accounts: List<BankAccount>, since: Long): List<ChatMessage> =
|
||||
ReceiptStore.loadAll(context).mapNotNull { entry ->
|
||||
val r = entry.data
|
||||
if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null
|
||||
val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null
|
||||
val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt
|
||||
val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel }
|
||||
ChatMessage(
|
||||
id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}",
|
||||
peerKey = normalise(r.toLabel),
|
||||
peerName = r.toLabel,
|
||||
amount = -amount,
|
||||
currency = r.currency,
|
||||
timeMillis = time,
|
||||
accountNumber = from?.accountNumber.orEmpty(),
|
||||
accountDisplayName = r.fromLabel,
|
||||
reference = r.bmlReference,
|
||||
note = r.remarks,
|
||||
peerAccount = r.toAccount,
|
||||
receiptKey = entry.savedAt.toString()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges stored and newly fetched messages so each transfer appears once. The same transfer can
|
||||
* come from history, a notification and a receipt; the highest-ranked source is kept (history,
|
||||
* then notification, then receipt) and picks up what the others add: the exact time, the note,
|
||||
* the recipient account and the receipt.
|
||||
*/
|
||||
private fun merge(existing: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||
val byId = LinkedHashMap<String, ChatMessage>()
|
||||
incoming.forEach { byId[it.id] = it }
|
||||
existing.forEach { byId[it.id] = it } // stored copies already carry merged details
|
||||
|
||||
val kept = mutableListOf<ChatMessage>()
|
||||
val absorbed = HashMap<String, MutableSet<Int>>() // kept id -> source ranks merged into it
|
||||
for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) {
|
||||
val r = rank(candidate)
|
||||
val index = kept.indexOfFirst { k ->
|
||||
rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate)
|
||||
}
|
||||
if (index >= 0) {
|
||||
val k = kept[index]
|
||||
kept[index] = k.copy(
|
||||
timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis,
|
||||
dateOnly = k.dateOnly && candidate.dateOnly,
|
||||
accountNumber = k.accountNumber.ifBlank { candidate.accountNumber },
|
||||
reference = k.reference.ifBlank { candidate.reference },
|
||||
note = k.note.ifBlank { candidate.note },
|
||||
peerAccount = k.peerAccount.ifBlank { candidate.peerAccount },
|
||||
receiptKey = k.receiptKey.ifBlank { candidate.receiptKey },
|
||||
merchant = k.merchant || candidate.merchant
|
||||
)
|
||||
absorbed.getOrPut(k.id) { mutableSetOf() } += r
|
||||
} else {
|
||||
kept += candidate
|
||||
}
|
||||
}
|
||||
return kept.sortedBy { it.timeMillis }
|
||||
}
|
||||
|
||||
private fun rank(m: ChatMessage) = when {
|
||||
m.id.startsWith("bml_") -> 0
|
||||
m.id.startsWith("ntf_") -> 1
|
||||
else -> 2
|
||||
}
|
||||
|
||||
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
|
||||
if (a.reference.isNotBlank() && a.reference == b.reference) return true
|
||||
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
|
||||
// A payment to a business is never the same as a transfer to a person (receipts aren't tagged).
|
||||
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
|
||||
if (!receipt && a.merchant != b.merchant) return false
|
||||
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false
|
||||
if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
|
||||
if (a.dateOnly || b.dateOnly) {
|
||||
// A date-only entry could be any transfer that day, so the names must agree too.
|
||||
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
|
||||
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
|
||||
}
|
||||
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
|
||||
}
|
||||
|
||||
private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply {
|
||||
timeInMillis = millis
|
||||
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||
}.timeInMillis
|
||||
|
||||
fun clearAll(context: Context) = synchronized(lock) {
|
||||
File(context.filesDir, FILE_NAME).delete()
|
||||
File(context.filesDir, PINS_FILE_NAME).delete()
|
||||
File(context.filesDir, QR_FILE_NAME).delete()
|
||||
}
|
||||
|
||||
// ─── Saved merchant QRs ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The static QR (no preset amount) last paid at the business named [merchantName], or null.
|
||||
* The app already remembers such QRs among its recents, but only the last few; they are
|
||||
* copied here so a business's QR is kept for good. Call off the main thread.
|
||||
*/
|
||||
fun merchantQr(context: Context, merchantName: String): String? = synchronized(lock) {
|
||||
val file = File(context.filesDir, QR_FILE_NAME)
|
||||
val saved = try {
|
||||
if (!file.exists()) JSONObject() else JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||
} catch (_: Exception) { JSONObject() }
|
||||
var changed = false
|
||||
for (recent in RecentsCache.load(context)) {
|
||||
if (!recent.accountNumber.startsWith(RECENT_QR_PREFIX)) continue
|
||||
val key = normalise(recent.displayName)
|
||||
val url = recent.accountNumber.removePrefix(RECENT_QR_PREFIX)
|
||||
if (key.isNotBlank() && saved.optString(key) != url) { saved.put(key, url); changed = true }
|
||||
}
|
||||
if (changed) try { file.writeText(CacheEncryption.encrypt(saved.toString())) } catch (_: Exception) {}
|
||||
|
||||
// Names differ slightly between the QR, history and alerts; accept one starting the other.
|
||||
val name = normalise(merchantName)
|
||||
val keys = saved.keys().asSequence().toList()
|
||||
val match = keys.firstOrNull { it == name }
|
||||
?: keys.filter { it.startsWith(name) || name.startsWith(it) }.maxByOrNull { it.length }
|
||||
match?.let { saved.optString(it).takeIf { url -> url.isNotBlank() } }
|
||||
}
|
||||
|
||||
// ─── Pinned chats ─────────────────────────────────────────────────────────
|
||||
|
||||
/** Pinned chat keys, most recently pinned first. */
|
||||
fun pins(context: Context): List<String> = synchronized(lock) {
|
||||
val file = File(context.filesDir, PINS_FILE_NAME)
|
||||
if (!file.exists()) return emptyList()
|
||||
try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun setPinned(context: Context, peerKey: String, pinned: Boolean) {
|
||||
val current = pins(context).filter { it != peerKey }
|
||||
val updated = if (pinned) listOf(peerKey) + current else current
|
||||
synchronized(lock) {
|
||||
try {
|
||||
File(context.filesDir, PINS_FILE_NAME)
|
||||
.writeText(CacheEncryption.encrypt(JSONArray(updated).toString()))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(context: Context): State {
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
if (!file.exists()) return State(0L, emptyList())
|
||||
return try {
|
||||
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
|
||||
val arr = root.optJSONArray("messages") ?: JSONArray()
|
||||
State(
|
||||
sinceMillis = root.optLong("since", 0L),
|
||||
messages = (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
ChatMessage(
|
||||
id = o.optString("id"),
|
||||
peerKey = o.optString("peerKey"),
|
||||
peerName = o.optString("peerName"),
|
||||
amount = o.optDouble("amount", 0.0),
|
||||
currency = o.optString("currency"),
|
||||
timeMillis = o.optLong("time", 0L),
|
||||
accountNumber = o.optString("account"),
|
||||
accountDisplayName = o.optString("accountName"),
|
||||
reference = o.optString("reference"),
|
||||
note = o.optString("note"),
|
||||
peerAccount = o.optString("peerAccount"),
|
||||
receiptKey = o.optString("receiptKey"),
|
||||
dateOnly = o.optBoolean("dateOnly", false),
|
||||
merchant = o.optBoolean("merchant", false)
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (_: Exception) { State(0L, emptyList()) }
|
||||
}
|
||||
|
||||
private fun save(context: Context, state: State) {
|
||||
try {
|
||||
val arr = JSONArray()
|
||||
for (m in state.messages) arr.put(JSONObject().apply {
|
||||
put("id", m.id)
|
||||
put("peerKey", m.peerKey)
|
||||
put("peerName", m.peerName)
|
||||
put("amount", m.amount)
|
||||
put("currency", m.currency)
|
||||
put("time", m.timeMillis)
|
||||
put("account", m.accountNumber)
|
||||
put("accountName", m.accountDisplayName)
|
||||
put("reference", m.reference)
|
||||
put("note", m.note)
|
||||
put("peerAccount", m.peerAccount)
|
||||
put("receiptKey", m.receiptKey)
|
||||
put("dateOnly", m.dateOnly)
|
||||
put("merchant", m.merchant)
|
||||
})
|
||||
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
|
||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="?attr/colorOutline" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<corners android:radius="14dp" />
|
||||
<solid android:color="?attr/colorSurfaceContainerHigh" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M20,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2H6V9zM14,14H6v-2h8v2zM18,8H6V6h12v2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||
android:pathData="M19.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2 7.5,3.5 6,2 4.5,3.5 3,2v20l1.5,-1.5L6,22l1.5,-1.5L9,22l1.5,-1.5L12,22l1.5,-1.5L15,22l1.5,-1.5L18,22l1.5,-1.5L21,22V2l-1.5,1.5zM19,19.09H5V4.91h14v14.18zM6,15h12v2H6zM6,11h12v2H6zM6,7h12v2H6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M4,9l1.5,-5h13L20,9M4,9v11h16V9M4,9h16M9,20v-6h6v6" />
|
||||
</vector>
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="8dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:text="Scan this with your authenticator app, or copy the seed. Please keep it private, since anyone who has it can generate your OTP codes."
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<!-- Always black-on-white so scanners read it in dark mode too -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="@android:color/white"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivSeedQr"
|
||||
android:layout_width="220dp"
|
||||
android:layout_height="220dp"
|
||||
android:layout_margin="12dp"
|
||||
android:contentDescription="OTP seed QR code" />
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:fontFamily="monospace"
|
||||
android:gravity="center"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textIsSelectable="true" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnCopySeed"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="Copy seed"
|
||||
app:icon="@drawable/ic_copy" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="24dp"
|
||||
android:paddingTop="8dp">
|
||||
|
||||
<!-- Warning: the old seed is overwritten -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
style="@style/Widget.Material3.CardView.Filled"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="16dp"
|
||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||
app:cardCornerRadius="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_info"
|
||||
app:tint="?attr/colorOnErrorContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSeedWarning"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnErrorContainer" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilNewSeed"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:hint="New OTP seed"
|
||||
app:helperText="Base32 secret or otpauth:// link">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etNewSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textNoSuggestions|textVisiblePassword"
|
||||
android:singleLine="true" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanNewSeed"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/scan_otp_qr"
|
||||
android:tooltipText="@string/scan_otp_qr"
|
||||
app:icon="@drawable/ic_qr_scan" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Live preview of the new seed's code, same card as the sign-in screen -->
|
||||
<include
|
||||
android:id="@+id/cardOtp"
|
||||
layout="@layout/view_otp_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -0,0 +1,330 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/toAccountBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSecondaryContainer"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/chat_to"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToNickname"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvToCurrency"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/bg_chat_chip"
|
||||
android:paddingHorizontal="10dp"
|
||||
android:paddingVertical="3dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivToSwitch"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:rotation="90"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/saveContactBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurfaceContainerHigh"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/chat_not_in_contacts"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSaveContact"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/chat_save_contact" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/summaryCard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:background="@drawable/bg_chat_summary"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:paddingVertical="10dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumLabel1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumValue1"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sumColumn2"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumLabel2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumValue2"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sumColumn3"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumLabel3"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSumValue3"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:textStyle="bold" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvMessages"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="8dp" />
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCannotSend"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="16dp"
|
||||
android:gravity="center"
|
||||
android:text="@string/chat_cannot_send"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/composer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrencyWarning"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorError"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnFrom"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start|center_vertical"
|
||||
android:ellipsize="middle"
|
||||
android:maxLines="1"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
app:icon="@drawable/ic_arrow_right"
|
||||
app:iconGravity="end"
|
||||
app:iconSize="16dp"
|
||||
app:iconTint="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/amountRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilAmount"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1.2"
|
||||
android:hint="@string/chat_amount"
|
||||
app:boxCornerRadiusBottomEnd="28dp"
|
||||
app:boxCornerRadiusBottomStart="28dp"
|
||||
app:boxCornerRadiusTopEnd="28dp"
|
||||
app:boxCornerRadiusTopStart="28dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etAmount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="numberDecimal"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilNote"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/chat_note"
|
||||
app:boxCornerRadiusBottomEnd="28dp"
|
||||
app:boxCornerRadiusBottomStart="28dp"
|
||||
app:boxCornerRadiusTopEnd="28dp"
|
||||
app:boxCornerRadiusTopStart="28dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etNote"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionSend"
|
||||
android:inputType="textCapSentences"
|
||||
android:maxLines="1" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnSend"
|
||||
style="@style/Widget.Material3.Button.Icon"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/chat_send"
|
||||
app:cornerRadius="28dp"
|
||||
app:icon="@drawable/ic_send"
|
||||
app:iconGravity="textEnd" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Hosts the Transfer page off-screen when only its confirm dialog is wanted. -->
|
||||
<FrameLayout
|
||||
android:id="@+id/hiddenTransferHost"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnPayAgain"
|
||||
style="@style/Widget.Material3.Button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:text="@string/chat_pay_again"
|
||||
android:visibility="gone"
|
||||
app:cornerRadius="28dp" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="12dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
|
||||
app:startIconDrawable="@android:drawable/ic_menu_search"
|
||||
app:boxCornerRadiusTopStart="24dp"
|
||||
app:boxCornerRadiusTopEnd="24dp"
|
||||
app:boxCornerRadiusBottomStart="24dp"
|
||||
app:boxCornerRadiusBottomEnd="24dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etSearch"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/chats_search_hint"
|
||||
android:inputType="text"
|
||||
android:maxLines="1"
|
||||
android:imeOptions="actionSearch" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvChats"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="65dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyView"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginHorizontal="32dp"
|
||||
android:gravity="center"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabScanPay"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/chat_scan_to_pay"
|
||||
app:srcCompat="@drawable/ic_qr_scan" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -130,89 +130,13 @@
|
||||
android:maxLength="6" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
<include
|
||||
android:id="@+id/cardOtp"
|
||||
layout="@layout/view_otp_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:visibility="invisible"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current OTP"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.15"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="end"
|
||||
android:alpha="0.7">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Next"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNextOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpTimer"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
app:indicatorSize="32dp"
|
||||
app:trackThickness="3dp"
|
||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||
app:trackColor="?attr/colorSecondaryContainer"
|
||||
android:indeterminate="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
android:layout_marginBottom="8dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvError"
|
||||
|
||||
@@ -304,6 +304,34 @@
|
||||
|
||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_receipts"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_always_fullscreen_receipt"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchFullscreenReceipt"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivAvatar"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
|
||||
android:scaleType="centerCrop"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvName"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||
app:layout_constraintEnd_toStartOf="@id/tvTime"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/tvPreview"
|
||||
app:layout_constraintVertical_chainStyle="packed" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintBaseline_toBaselineOf="@id/tvName" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPreview"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
app:layout_constraintStart_toEndOf="@id/ivAvatar"
|
||||
app:layout_constraintEnd_toStartOf="@id/ivPin"
|
||||
app:layout_constraintTop_toBottomOf="@id/tvName"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivPin"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:contentDescription="@string/chat_pinned"
|
||||
android:src="@drawable/ic_pin"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/tvPreview"
|
||||
app:layout_constraintBottom_toBottomOf="@id/tvPreview" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,109 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/bubbleRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingVertical="3dp">
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/cardBubble"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="150dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="14dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAmount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="240dp"
|
||||
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnReceipt"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="-8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/chat_view_receipt"
|
||||
android:padding="8dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/ic_receipt"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNote"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:maxWidth="240dp"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxWidth="150dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:alpha="0.8"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTime"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:alpha="0.8"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvTicks"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingVertical="10dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingVertical="4dp"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/transferContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/ivHeaderAvatar"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:scaleType="centerCrop"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHeaderName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvHeaderAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,84 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Live TOTP preview card, shared by the sign-in screen and the OTP screen's "Update seed" dialog -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="invisible"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||
app:cardCornerRadius="12dp"
|
||||
app:cardElevation="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="16dp"
|
||||
android:paddingVertical="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Current OTP"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.15"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:orientation="vertical"
|
||||
android:gravity="end"
|
||||
android:alpha="0.7">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Next"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNextOtpCode"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:textColor="?attr/colorOnSecondaryContainer"
|
||||
android:letterSpacing="0.1"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||
android:id="@+id/otpTimer"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
app:indicatorSize="32dp"
|
||||
app:trackThickness="3dp"
|
||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||
app:trackColor="?attr/colorSecondaryContainer"
|
||||
android:indeterminate="false" />
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -6,9 +6,9 @@
|
||||
<item android:id="@+id/nav_accounts"
|
||||
android:icon="@drawable/ic_nav_accounts"
|
||||
android:title="@string/nav_accounts" />
|
||||
<item android:id="@+id/nav_contacts"
|
||||
android:icon="@drawable/ic_contacts"
|
||||
android:title="@string/nav_contacts" />
|
||||
<item android:id="@+id/nav_chats"
|
||||
android:icon="@drawable/ic_chat"
|
||||
android:title="@string/nav_chats" />
|
||||
<item android:id="@+id/nav_transfer"
|
||||
android:icon="@drawable/ic_send"
|
||||
android:title="@string/transfer" />
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
<item android:id="@+id/nav_pay_mv_qr"
|
||||
android:icon="@drawable/ic_qr_scan"
|
||||
android:title="@string/pay_mv_qr" />
|
||||
<item android:id="@+id/nav_chats"
|
||||
android:icon="@drawable/ic_chat"
|
||||
android:title="@string/nav_chats" />
|
||||
<item android:id="@+id/nav_contacts"
|
||||
android:icon="@drawable/ic_contacts"
|
||||
android:title="@string/nav_contacts" />
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="action_open_contacts" type="id" />
|
||||
<item name="action_pin_chat" type="id" />
|
||||
</resources>
|
||||
@@ -85,6 +85,7 @@
|
||||
<string name="nav_add_account">Add Login</string>
|
||||
<string name="nav_accounts">Accounts</string>
|
||||
<string name="nav_contacts">Contacts</string>
|
||||
<string name="nav_chats">Chats</string>
|
||||
<string name="nav_activities">Recent Transfers</string>
|
||||
<string name="nav_transfer_history">Transaction History</string>
|
||||
<string name="nav_finances">Finances</string>
|
||||
@@ -94,6 +95,7 @@
|
||||
<string name="nav_more">More</string>
|
||||
<string name="nav_desc_accounts">View all your bank accounts</string>
|
||||
<string name="nav_desc_contacts">Manage your transfer contacts</string>
|
||||
<string name="nav_desc_chats">Your transfers with each person, as chats</string>
|
||||
<string name="nav_desc_transfer">Send money to a contact</string>
|
||||
<string name="nav_desc_pay_mv_qr">Scan or generate a PayMV QR code</string>
|
||||
<string name="nav_desc_activities">View your recent transfers</string>
|
||||
@@ -157,6 +159,8 @@
|
||||
<string name="theme_dark">Dark</string>
|
||||
<string name="settings_pitch_black">Pitch Black</string>
|
||||
<string name="settings_accent_color">Accent Color</string>
|
||||
<string name="settings_receipts">Receipts</string>
|
||||
<string name="settings_always_fullscreen_receipt">Always show full screen receipt</string>
|
||||
<string name="accent_blue">Blue</string>
|
||||
<string name="accent_orange">Red</string>
|
||||
<string name="accent_green">Green</string>
|
||||
@@ -294,6 +298,7 @@
|
||||
<!-- BML QR Pay -->
|
||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
|
||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||
|
||||
@@ -399,4 +404,60 @@
|
||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
||||
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
||||
<string name="drag_to_reorder">Drag to reorder</string>
|
||||
|
||||
<!-- Chats -->
|
||||
<string name="chats_search_hint">Search chats</string>
|
||||
<string name="chats_empty">Chats start from now. New BML transfers will show up here.</string>
|
||||
<string name="chats_empty_since">No transfers since %s. New BML transfers will show up here.</string>
|
||||
<string name="chats_no_results">No chats found</string>
|
||||
<string name="chat_preview_sent">You sent %s</string>
|
||||
<string name="chat_preview_received">Received %s</string>
|
||||
<string name="chat_preview_paid">Paid %s</string>
|
||||
<string name="chat_today">Today</string>
|
||||
<string name="chat_yesterday">Yesterday</string>
|
||||
<string name="chat_from_account">From account</string>
|
||||
<string name="chat_pick_account">Send from</string>
|
||||
<string name="chat_pick_card">Pay with</string>
|
||||
<string name="chat_from_chip">%1$s · %2$s · %3$s</string>
|
||||
<string name="chat_currency_warning">⚠ Sending USD to an MVR account. It will be converted at BML\'s rate and can\'t be reversed.</string>
|
||||
<string name="chat_currency_blocked">MVR can\'t be sent to a USD account. Pick a USD account to send from.</string>
|
||||
<string name="chat_cant_send_mvr_to_usd">Can\'t send MVR to a USD account</string>
|
||||
<string name="chat_needs_usd_source">Needs a USD account to send from</string>
|
||||
<string name="chat_transfer_sent">Transfer sent</string>
|
||||
<string name="chat_payment_sent">Payment sent</string>
|
||||
<string name="chat_view_receipt">Receipt</string>
|
||||
<string name="chat_not_in_contacts">Not in your contacts</string>
|
||||
<string name="chat_save_contact">Save contact</string>
|
||||
<string name="chat_send">Send</string>
|
||||
<string name="chat_to">To</string>
|
||||
<string name="chat_pick_recipient_account">Send to</string>
|
||||
<string name="chat_receipt_missing">Receipt no longer available</string>
|
||||
<string name="chat_amount">Amount</string>
|
||||
<string name="chat_amount_invalid">Enter an amount</string>
|
||||
<string name="chat_cannot_send">Account number not known for this chat. Save this person as a contact, or send once from Transfer, to send from here.</string>
|
||||
<string name="chat_into_short">into %s</string>
|
||||
<string name="chat_account_to">%1$s → %2$s</string>
|
||||
<string name="chat_tick_seen">Seen in BML alert</string>
|
||||
<string name="chat_tick_booked">Booked in BML history</string>
|
||||
<string name="chat_note">Note</string>
|
||||
<string name="chat_pay_again">Pay again with Scan to Pay</string>
|
||||
<string name="chat_pay_business">Pay %s</string>
|
||||
<string name="chat_scan_to_pay">Scan to Pay</string>
|
||||
<string name="chat_merchant_subtitle">Business · Scan to Pay & card</string>
|
||||
<string name="chat_sum_sent">%s · sent</string>
|
||||
<string name="chat_sum_received">received</string>
|
||||
<string name="chat_sum_net">net</string>
|
||||
<string name="chat_sum_spent">Spent here in %s</string>
|
||||
<string name="chat_sum_payments">payments</string>
|
||||
<string name="chat_send_again">Send again · %s</string>
|
||||
<string name="chat_view_receipt_full">View receipt</string>
|
||||
<string name="chat_copy_reference">Copy reference</string>
|
||||
<string name="chat_reference_copied">Reference copied</string>
|
||||
<string name="chat_pinned">Pinned</string>
|
||||
<string name="chat_pin">Pin</string>
|
||||
<string name="chat_unpin">Unpin</string>
|
||||
<string name="chat_selected_one">1 selected</string>
|
||||
<string name="chat_pinned_msg">%s pinned</string>
|
||||
<string name="chat_unpinned_msg">%s unpinned</string>
|
||||
<string name="chat_undo">Undo</string>
|
||||
</resources>
|
||||
|
||||
@@ -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**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# FAQ
|
||||
|
||||
## [What is and how do i get my TOTP Seed?](totpseed/README.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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="25%">Step 1</th>
|
||||
<th width="25%">Step 2</th>
|
||||
<th width="25%">Step 3</th>
|
||||
<th width="25%">Step 4</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/bml_1.jpg" alt="BML app wallet screen with the profile icon highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_2.jpg" alt="Profile menu with Authenticator Setup highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_3.jpg" alt="Channel Settings screen with the Reset Authenticator button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_4.jpg" alt="Debit card verification form with the Authorize button highlighted" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Open your profile</b><br>
|
||||
In the BML app, tap the <b>profile icon</b> in the top-right corner of the Wallet screen.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Open Authenticator Setup</b><br>
|
||||
In the menu, under <b>Settings</b>, tap <b>Authenticator Setup</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Reset the authenticator</b><br>
|
||||
On the <b>Security</b> tab, tap <b>Reset Authenticator</b>. This replaces any authenticator app you used before.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Verify your debit card</b><br>
|
||||
Pick a debit card, enter its expiry month, expiry year and CVC, then tap <b>Authorize</b>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Step 5</th>
|
||||
<th>Step 6</th>
|
||||
<th>Step 7</th>
|
||||
<th>Done</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/bml_5.jpg" alt="QR code screen with the copy button next to the secret key highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_1.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_6.jpg" alt="BML screen with the OTP entered and the Verify Code button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bml_7.jpg" alt="Authenticator reset successfully message" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Copy the secret key</b><br>
|
||||
Below the QR code, tap the <b>copy button</b> next to the secret key. Keep this screen open, you will come back to it.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Add the seed to Thijooree</b><br>
|
||||
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. Tap the <b>Current OTP</b> box to copy the 6-digit code.<br><br>
|
||||
<i>Adding the key to another authenticator app? Do it now, before step 7.</i>
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Verify the code in BML</b><br>
|
||||
Go back to the BML app, paste the code into the 6-digit code field and tap <b>Verify Code</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>All done</b><br>
|
||||
BML shows <b>Authenticator reset successfully</b>. Thijooree now generates your BML OTP codes.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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.
|
||||
@@ -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**<br>
|
||||
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**<br>
|
||||
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**<br>
|
||||
MIB emails you the new secret key. Eventually. Check your inbox, then check your spam folder, then check your inbox again.
|
||||
|
||||
4. **Wait more**<br>
|
||||
Still nothing? This is normal. Refresh your inbox. Touch grass. Refresh your inbox again.
|
||||
|
||||
5. **Perform another ritual**<br>
|
||||
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**<br>
|
||||
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.
|
||||
@@ -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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="25%">Step 1</th>
|
||||
<th width="25%">Step 2</th>
|
||||
<th width="25%">Step 3</th>
|
||||
<th width="25%">Step 4</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/google_1.jpg" alt="Google Authenticator home screen with the menu button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_2.jpg" alt="Google Authenticator menu with Transfer codes highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_3.jpg" alt="Select codes screen with only MIB checked and the Next button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_4.jpg" alt="Scan this QR code screen showing the export QR code" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Open the menu</b><br>
|
||||
In Google Authenticator, tap the <b>menu button</b> (three lines) in the top-left corner.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Open Transfer codes</b><br>
|
||||
Tap <b>Transfer codes</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Select your bank account</b><br>
|
||||
Check <b>only</b> the bank account you want to log in to on Thijooree, then tap <b>Next</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Screenshot the QR code</b><br>
|
||||
Take a <b>screenshot</b> of the QR code. Keep this screen open, you will come back to it.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Step 5</th>
|
||||
<th>Step 6</th>
|
||||
<th>Step 7</th>
|
||||
<th>Step 8</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/thijooree_2.jpg" alt="Thijooree sign-in screen with the QR button next to the OTP seed field highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_3.jpg" alt="Thijooree QR scanner with the Pick image button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_4.jpg" alt="Photo picker with the QR code screenshot selected" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_5.jpg" alt="Google Authenticator Scan this QR code screen with the Next button highlighted" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Open the QR scanner</b><br>
|
||||
Open Thijooree and, on the sign-in screen, tap the <b>QR button</b> next to <b>OTP Seed (TOTP Secret)</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Pick an image</b><br>
|
||||
The QR code is on the same phone, so there is nothing to scan. Tap <b>Pick image</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Select the screenshot</b><br>
|
||||
Select the QR code screenshot from step 4. Thijooree fills in the OTP seed for you.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Finish the export</b><br>
|
||||
Go back to Google Authenticator and tap <b>Next</b> on the QR code screen.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Step 9</th>
|
||||
<th colspan="2">Step 10</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/google_6.jpg" alt="Remove your exported codes screen with Keep exported codes and Done highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/google_7.jpg" alt="Google Authenticator list with the MIB code highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_5.jpg" alt="Thijooree sign-in screen with the Current OTP highlighted, matching Google Authenticator" width="200"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Keep the exported codes</b><br>
|
||||
Select <b>Keep exported codes</b> and tap <b>Done</b>. Don't remove them, or the account disappears from Google Authenticator.
|
||||
</td>
|
||||
<td valign="top" colspan="2">
|
||||
<b>Check that the codes match</b><br>
|
||||
Compare the <b>Current OTP</b> in Thijooree with the code for the same account in Google Authenticator. They should be the same, because both use the same seed.
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="20%">Step 1</th>
|
||||
<th width="20%">Step 2</th>
|
||||
<th width="20%">Step 3</th>
|
||||
<th width="20%">Step 4</th>
|
||||
<th width="20%">Done</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/msauth_1.jpg" alt="Microsoft Authenticator home screen with the QR code button highlighted" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_2.jpg" alt="Scan QR Code screen with the Enter code manually button highlighted" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_3.jpg" alt="Add account screen with Other account highlighted" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_4.jpg" alt="Add account form with account name and secret key filled in" width="160"></td>
|
||||
<td align="center"><img src="screenshots/msauth_5.jpg" alt="Bank of Maldives account in the Microsoft Authenticator list showing a 6-digit code" width="160"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Add an account</b><br>
|
||||
Open Microsoft Authenticator and tap the <b>QR code button</b> in the bottom-right corner.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Enter the code manually</b><br>
|
||||
BML is on the same phone, so there is nothing to scan. Tap <b>Enter code manually</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Choose the account type</b><br>
|
||||
Tap <b>Other account (Google, Facebook, etc.)</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Paste the secret key</b><br>
|
||||
Enter an <b>Account Name</b> such as <i>Bank of Maldives</i>, paste the key from BML into <b>Secret Key</b> and tap <b>Finish</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Account added</b><br>
|
||||
The account now shows a 6-digit code. It matches the code in Thijooree because both use the same key.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
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).
|
||||
@@ -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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th width="25%">Step 1</th>
|
||||
<th width="25%">Step 2</th>
|
||||
<th width="25%">Step 3</th>
|
||||
<th width="25%">Step 4</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><img src="screenshots/bitwarden_1.jpg" alt="Bitwarden View login screen for Bank of Maldives with the edit button highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bitwarden_2.jpg" alt="Bitwarden Edit login screen with the copy button next to the Authenticator key highlighted" width="200"></td>
|
||||
<td align="center"><img src="screenshots/thijooree_6.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
|
||||
<td align="center"><img src="screenshots/bitwarden_3.jpg" alt="Bitwarden View login screen with the Authenticator key code highlighted, matching Thijooree" width="200"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<b>Edit your bank login</b><br>
|
||||
In Bitwarden, open the login for your bank and tap the <b>edit button</b> in the bottom-right corner.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Copy the authenticator key</b><br>
|
||||
Tap the <b>copy button</b> next to <b>Authenticator key</b>.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Add the seed to Thijooree</b><br>
|
||||
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. The <b>Current OTP</b> appears below it.
|
||||
</td>
|
||||
<td valign="top">
|
||||
<b>Check that the codes match</b><br>
|
||||
Go back to Bitwarden, close the edit screen without saving and compare the <b>Authenticator key</b> code with the <b>Current OTP</b> in Thijooree. They should be the same, because both use the same seed.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 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.
|
||||
@@ -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)
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1 @@
|
||||
- Export and Update OTP seeds
|
||||
@@ -0,0 +1 @@
|
||||
- New setting to always show recipt full screen
|
||||
@@ -0,0 +1 @@
|
||||
- Pay with BML Transaction ID (On BML Pay supported transactions"
|
||||