Compare commits

..
7 Commits
Author SHA1 Message Date
azuwlaaandClaude Opus 5.5 da5cca79c6 add chats: transfers grouped per person as a chat
Replaces Contacts in the default bottom nav slot with a Chats page
(Contacts moves to More and opens from the Chats toolbar).

- Chats start when the page is first opened; no old history is loaded
- Transfers come from BML notifications (instant), account history
  (booked later) and in-app receipts, merged into one bubble each
- Chats are keyed by the account holder's real name, looked up via
  BML account validation, so nickname contacts and their MVR/USD
  accounts share one chat; the To bar picks the account to send to
- Sending opens the Transfer page in a bottom sheet over the chat and
  returns to it on success; sent bubbles link to their receipt
- Chat and name data are stored encrypted with CacheEncryption

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-25 17:29:01 +05:00
shihaam a37354fc82 release version 1.0.27
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m5s
2026-09-25 07:03:40 +05:00
shihaam 66568a464a share contact account numbers and PayMV QR for contacts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:53:15 +05:00
shihaam 5210e6207f redegsigned contacts page: removed delete and edit buttons from contacts list, made a new contacts sheet and moved buttons there
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-25 06:35:39 +05:00
shihaam 369c6e4a37 redegsigned OTP page and show next TOTP code
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 06:18:09 +05:00
shihaam fbedc631c5 prep for fdroid
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:53:53 +05:00
shihaam 7d6c192a63 update ci to use owner-repo name as title and apk name
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-25 05:25:09 +05:00
82 changed files with 3263 additions and 167 deletions
+13 -16
View File
@@ -27,24 +27,21 @@ jobs:
- name: Rename APK
run: |
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}"
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
- name: Extract release notes
run: |
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
if [ -f "$CHANGELOG" ]; then
cp "$CHANGELOG" release_notes.md
else
echo "No changelog found at $CHANGELOG"
echo "No release notes" > release_notes.md
# VERSION="${{ gitea.ref_name }}"
# VERSION="${VERSION#v}"
#
# awk -v ver="$VERSION" '
# BEGIN { found=0 }
# /^## \[/ {
# if (found) exit
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
# }
# found { print }
# ' CHANGELOG.md > release_notes.md
fi
- name: Create Gitea Release
env:
@@ -53,7 +50,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
run: |
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}"
TITLE="${APP_NAME} ${TAG}"
NOTES_FILE="release_notes.md"
@@ -101,12 +98,12 @@ jobs:
fi
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
CAPTION="${APP_NAME} ${TAG}"
CAPTION="$(printf '%s\n\n%s' "${APP_NAME} ${TAG}" "$(cat release_notes.md)" | head -c 1024)"
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
-F "chat_id=${TG_CHAT_ID}" \
-F "document=@${ASSET_PATH}" \
-F "caption=${CAPTION}"
--form-string "caption=${CAPTION}"
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 27
versionName = "1.0.26"
versionCode = 28
versionName = "1.0.27"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -0,0 +1,398 @@
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.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. 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 and amount, so the usual confirm/OTP flow still applies.
*/
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 { openReceipt(it.receiptKey) }
private var thread: ChatThread? = null
private var fromAccounts: List<BankAccount> = emptyList()
private var selectedFrom: BankAccount? = null
private var selectedTo: ChatAccount? = null
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)
selectedFrom?.let { acc -> bindAccountRow(binding.fromAccountRow, acc) }
}
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
bindFromAccounts()
}
binding.cardFromAccount.setOnClickListener { showAccountPicker() }
binding.btnSaveContact.setOnClickListener { openContact() }
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
binding.btnSend.setOnClickListener { send() }
binding.etAmount.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
}
// 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)
updateToolbar()
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)
}
}
/** 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
b.toAccountBar.visibility = if (to == null) 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)
}
}
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.tvDropdownAccountType.visibility = View.GONE
row.ivDropdownCardLogo.visibility = View.GONE
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
row.root.setOnClickListener {
selectTo(account, matchFromCurrency = true)
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() {
binding.etAmount.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()
}
Snackbar.make(binding.root, R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
.setAnchorView(binding.composer)
.setAction(R.string.chat_view_receipt) {
val latest = ReceiptStore.loadAll(requireContext()).firstOrNull() ?: return@setAction
openReceipt(latest.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
bindAccountRow(b.fromAccountRow, account)
b.tilAmount.prefixText = account.currencyName
}
/** 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)
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
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
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 = null,
fromAccountNumber = from.accountNumber,
returnOnSuccess = true
)
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
}
/** 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
// Show the real account-holder name when the chat is titled with a nickname.
val subtitle = t.realName.takeIf { !it.equals(t.peerName, ignoreCase = true) }.orEmpty()
h.tvHeaderAccount.text = subtitle
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
val sizePx = (40 * resources.displayMetrics.density).toInt()
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
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 val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
fun newInstance(peerKey: String) = ChatFragment().apply {
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
}
}
}
@@ -0,0 +1,162 @@
package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.text.format.DateUtils
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
import sh.sar.basedbank.databinding.ItemChatDateBinding
import sh.sar.basedbank.util.ChatMessage
/** Transfers with one person as chat bubbles: sent on the right, received on the left. */
class ChatMessagesAdapter(
private val onReceiptClick: (ChatMessage) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class DateChip(val label: String) : Item()
data class Bubble(val message: ChatMessage) : Item()
}
private val items = mutableListOf<Item>()
private var hideAmounts = false
/** 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) {
items.clear()
var lastDay = ""
for (m in messages) {
val day = m.date.take(10)
if (day != lastDay) {
val label = when {
DateUtils.isToday(m.timeMillis) -> todayLabel
DateUtils.isToday(m.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
else -> AccountHistoryAdapter.formatDateHeader(m.date)
}
items.add(Item.DateChip(label))
lastDay = day
}
items.add(Item.Bubble(m))
}
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).b.tvDate.text = item.label
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
}
}
class DateVH(val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root)
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(m: ChatMessage) {
val ctx = b.root.context
val sent = m.isSent
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
(b.tvMeta.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
// Sent: tinted like the mockup. Received: neutral grey with an outline, so the two
// read apart even on a red-tinted theme.
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
else Color.parseColor(if (night) "#2A2C2E" else "#F1F2F4")
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)
b.cardBubble.setCardBackgroundColor(bg)
b.cardBubble.strokeWidth = if (sent) 0 else (1 * ctx.resources.displayMetrics.density).toInt()
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
b.tvLabel.setTextColor(fg)
b.tvAmount.setTextColor(fg)
b.tvAccount.setTextColor(fg)
b.tvNote.setTextColor(fg)
b.btnReceipt.setColorFilter(fg)
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
b.tvLabel.text = ctx.getString(if (sent) R.string.chat_sent_label else R.string.chat_received_label)
// Own account on its own line so long names aren't cut off by the receipt icon and tag.
b.tvAccount.text = when {
!sent -> ctx.getString(R.string.chat_into_label, m.accountDisplayName)
showDestination && m.peerAccount.isNotBlank() ->
ctx.getString(R.string.chat_from_to_label, m.accountDisplayName, m.peerAccount)
else -> ctx.getString(R.string.chat_from_label, m.accountDisplayName)
}
b.tvAccount.visibility = if (m.accountDisplayName.isBlank()) View.GONE else View.VISIBLE
b.tvBadge.text = ctx.getString(if (sent) R.string.chat_debit else R.string.chat_credit)
// Debit uses the bubble's own text colour; Credit a green that stays readable in both themes.
val badgeFg = if (sent) fg else Color.parseColor(if (night) "#81C784" else "#2E7D32")
b.tvBadge.setTextColor(badgeFg)
b.tvBadge.background = GradientDrawable().apply {
cornerRadius = 10 * ctx.resources.displayMetrics.density
setColor(androidx.core.graphics.ColorUtils.setAlphaComponent(badgeFg, 0x2E))
}
b.tvAmount.text = if (hideAmounts) "${m.currency} ••••••"
else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
b.tvNote.text = m.note
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
val time = AccountHistoryAdapter.formatTime(m.date)
b.tvMeta.text = if (sent) ctx.getString(R.string.chat_sent_meta, time) else time
b.cardBubble.setOnClickListener { showDetail(m) }
}
private fun showDetail(m: ChatMessage) {
val ctx = b.root.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}")
}
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,93 @@
package sh.sar.basedbank.ui.home
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
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, latest transfer as the preview. */
class ChatsAdapter(
private val onChatClick: (ChatThread) -> Unit
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
private var allThreads: List<ChatThread> = emptyList()
private var displayed: List<ChatThread> = emptyList()
private var searchQuery = ""
private var hideAmounts = false
fun updateThreads(threads: List<ChatThread>) {
allThreads = threads
applyFilter()
}
fun setSearch(query: String) {
searchQuery = query
applyFilter()
}
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
val isEmpty get() = displayed.isEmpty()
private fun applyFilter() {
displayed = 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) }
}
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])
}
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 last = thread.last
binding.tvName.text = thread.peerName
val amount = if (hideAmounts) "${last.currency} ••••••"
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
binding.tvPreview.text = if (last.isSent) {
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
ctx.getString(R.string.chat_preview_sent, amount) + note
} else {
ctx.getString(R.string.chat_preview_received, amount)
}
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()
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
}
}
companion object {
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
/** 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]
}
}
@@ -0,0 +1,138 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
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.core.view.MenuProvider
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 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.databinding.FragmentChatsBinding
import sh.sar.basedbank.util.ChatStore
import java.text.DateFormat
import java.util.Date
/** Transfers grouped by person, shown as a chat list. Contacts is reachable from the toolbar. */
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 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 { thread ->
(requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
}
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
binding.rvChats.adapter = adapter
binding.etSearch.addTextChangedListener { text ->
adapter.setSearch(text?.toString() ?: "")
updateEmptyView()
}
binding.swipeRefresh.setOnRefreshListener { sync() }
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 = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
if (_binding == null) return@launch
adapter.updateThreads(threads)
updateEmptyView()
}
}
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() {
super.onDestroyView()
_binding = null
}
}
@@ -0,0 +1,208 @@
package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.SheetContactDetailsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.TransferNetwork
/** 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? ContactSheetHost
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val args = requireArguments()
val contactId = args.getString(ARG_CONTACT_ID)
val profileId = args.getString(ARG_PROFILE_ID)
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId }
?.let { ContactListParser.from(it) }
if (contact == null) dismissAllowingStateLoss() else bind(contact)
}
}
private fun bind(contact: ContactDisplay) {
val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2
val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) }
binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx))
binding.tvName.text = contact.name
binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE
// QR stays visible for every contact; greyed out where PayMV QR isn't supported yet
val canQr = canShowPayMvQr(contact)
binding.btnQr.isEnabled = canQr
binding.tvQrLabel.alpha = if (canQr) 1f else 0.38f
binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE
binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE
binding.btnTransfer.setOnClickListener {
val parent = contactsFragment
dismiss()
parent?.openTransfer(contact)
}
binding.btnQr.setOnClickListener {
val activity = requireActivity() as HomeActivity
val bank = contactBank(contact) ?: return@setOnClickListener
val qrName = contact.realName.ifBlank { contact.name }
dismiss()
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
}
binding.btnEdit.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnDelete.setOnClickListener {
val parent = contactsFragment
dismiss()
parent?.confirmDelete(contact)
}
binding.tvAccount.text = contact.accountNumber
val copy = View.OnClickListener { copyAccount(contact.accountNumber) }
binding.rowAccount.setOnClickListener(copy)
binding.btnCopyAccount.setOnClickListener(copy)
binding.btnShareAccount.setOnClickListener {
shareAccount(contact.realName.ifBlank { contact.name }, contact.accountNumber)
}
val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name
binding.tvRealName.text = contact.realName
binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE
binding.tvCurrency.text = contact.currency ?: ""
binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE
binding.tvBank.text = contact.bankName ?: ""
binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE
val bankLogo = bankLogoRes(contact)
if (bankLogo != null) {
binding.ivBankIcon.setImageResource(bankLogo)
binding.ivBankIcon.imageTintList = null
}
binding.sourceSection.visibility = View.GONE
viewLifecycleOwner.lifecycleScope.launch {
val appContext = requireContext().applicationContext
val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) }
val b = _binding ?: return@launch
if (source == null) return@launch
b.ivSourceIcon.setImageResource(source.logoRes)
b.tvSource.text = source.name
b.tvSourceBank.setText(source.bankNameRes)
b.sourceSection.visibility = View.VISIBLE
}
}
/** Which bank the contact's account is at ("BML" / "MIB"); null when we can't tell. */
private fun contactBank(contact: ContactDisplay): String? = when {
contact.network == TransferNetwork.MIB -> "MIB"
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> "BML"
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> "MIB"
else -> null
}
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
private fun bankLogoRes(contact: ContactDisplay): Int? = when (contactBank(contact)) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_logo
else -> null
}
/**
* PayMV QR is only offered for contacts whose account is at BML for now.
* TODO: enable for MIB and other banks once their PayMV QR payloads are verified —
* PayMvQrFragment.forContact already takes the bank, so this check is the only gate.
*/
private fun canShowPayMvQr(contact: ContactDisplay): Boolean =
contactBank(contact) == "BML" &&
contact.currency?.contains("USD", ignoreCase = true) != true // PayMV QR is MVR only
private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String)
/** Which of the user's own bank profiles this contact is saved under. */
private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? {
val store = CredentialStore(context)
return when (contact.network) {
TransferNetwork.BML -> {
// BML contacts carry either the loginId or a BML profileId
val loginId = store.getBmlLoginIds().firstOrNull { loginId ->
loginId == contact.profileId ||
store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId }
} ?: return null
val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name)
?: return null
SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name)
}
TransferNetwork.FAHIPAY -> {
val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId ->
store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
} ?: return null
SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name)
}
else -> {
val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId ->
store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
} ?: return null
SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name)
}
}
}
private fun shareAccount(accountName: String, account: String) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "$accountName\n$account")
}
startActivity(Intent.createChooser(intent, getString(R.string.contact_share_account)))
}
private fun copyAccount(account: String) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_CONTACT_ID = "contact_id"
private const val ARG_PROFILE_ID = "profile_id"
fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply {
arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId)
}
}
}
@@ -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)
}
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (ContactDisplay) -> Unit,
private val onContactClick: (ContactDisplay) -> Unit,
private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
@@ -66,12 +65,9 @@ class ContactsAdapter(
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
}
binding.btnEditContact.setOnClickListener {
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnDeleteContact.setOnClickListener {
binding.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
}
binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
@@ -107,24 +103,22 @@ class ContactsAdapter(
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility =
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnEditContact.visibility =
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility =
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo)
} else {
binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.name, contact.bankColor)
)
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = binding.ivContactPhoto.context.resources
.getDimensionPixelSize(android.R.dimen.app_icon_size)
.coerceAtLeast(96)
binding.ivContactPhoto.setImageBitmap(
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
)
}
}
}
}
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
@@ -138,6 +132,4 @@ class ContactsAdapter(
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
}
}
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import android.text.style.StyleSpan
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
@@ -33,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!!
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
ContactsAdapter(
imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) },
onContactClick = { contact -> showDetails(contact) },
onTransferClick = { contact -> openTransfer(contact) }
).also { a ->
a.setFilter(page.categoryId, currentSearch)
@@ -170,7 +177,12 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
}
private fun openTransfer(contact: ContactDisplay) {
private fun showDetails(contact: ContactDisplay) {
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
}
override fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber,
displayName = contact.name,
@@ -181,10 +193,26 @@ class ContactsFragment : Fragment() {
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
private fun confirmDelete(contact: ContactDisplay) {
MaterialAlertDialogBuilder(requireContext())
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) }
val prompt = getString(R.string.contact_delete_message, contact.name)
val message = SpannableStringBuilder(prompt).apply {
val start = prompt.indexOf(contact.name)
if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
append("\n").append(contact.accountNumber)
append("\n\n")
val warnStart = length
append(getString(R.string.contact_delete_warning))
setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog)
.setIcon(icon)
.setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.name))
.setMessage(message)
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(R.string.cancel, null)
.show()
@@ -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()
@@ -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()
}
@@ -18,7 +18,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibLoginFlow
@@ -32,7 +34,7 @@ class OtpFragment : Fragment() {
private var _binding: FragmentOtpBinding? = null
private val binding get() = _binding!!
private data class OtpEntry(val label: String, val seed: String)
private data class OtpEntry(val bank: String, val name: String?, val seed: String)
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
RecyclerView.Adapter<OtpAdapter.VH>() {
@@ -45,18 +47,17 @@ class OtpFragment : Fragment() {
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) {
holder.b.tvOtpLabel.text = entries[position].label
update(holder.b, entries[position].seed)
holder.b.root.setOnClickListener {
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
}
}
}
val entry = entries[position]
val b = holder.b
b.tvOtpBank.text = entry.bank
b.tvOtpLabel.text = entry.name ?: "Authenticator"
b.ivBankLogo.setImageResource(
if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
)
update(b, entry.seed)
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
}
fun tick() {
@@ -71,9 +72,32 @@ class OtpFragment : Fragment() {
val secondsInPeriod = (epochSeconds % 30).toInt()
val remaining = 30 - secondsInPeriod
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" }
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
b.otpProgress.progress = remaining
b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}"
b.tvOtpCountdown.text = remaining.toString()
// Turn the ring and code red in the last few seconds of the window
val expiring = remaining <= 5
val accent = MaterialColors.getColor(b.root,
if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary)
b.otpProgress.setIndicatorColor(accent)
b.tvOtpCode.setTextColor(accent)
b.tvOtpCountdown.setTextColor(
if (expiring) accent
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
)
}
}
private fun copyCode(context: Context, text: CharSequence, message: String) {
val code = text.toString().replace(" ", "")
if (code.isEmpty() || code.contains('-')) return
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
@@ -91,18 +115,19 @@ class OtpFragment : Fragment() {
for (loginId in store.getMibLoginIds()) {
val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
}
for (loginId in store.getBmlLoginIds()) {
val creds = store.loadBmlCredentials(loginId) ?: continue
val name = store.loadBmlUserProfile(loginId)?.fullName
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
}
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
val adapter = OtpAdapter(entries)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
// Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch {
@@ -124,7 +149,7 @@ class OtpFragment : Fragment() {
))
val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
}
}
}
@@ -145,7 +170,7 @@ class OtpFragment : Fragment() {
))
val seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
}
}
}
@@ -48,6 +48,8 @@ class PayMvQrFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null
/** Set when opened for a saved contact: the QR is for their account, not one of ours. */
private var contactTarget: QrTarget? = null
private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
@@ -59,7 +61,17 @@ class PayMvQrFragment : Fragment() {
return binding.root
}
/** Who the QR pays into — one of our own accounts, or a contact's. */
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
private fun currentTarget(): QrTarget? = contactTarget
?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
contactTarget = arguments?.let { args ->
val number = args.getString(ARG_ACCOUNT_NUMBER) ?: return@let null
QrTarget(number, args.getString(ARG_ACCOUNT_NAME).orEmpty(), args.getString(ARG_BANK).orEmpty())
}
val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
@@ -68,7 +80,15 @@ class PayMvQrFragment : Fragment() {
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
insets
}
if (contactTarget != null) {
// Not our account, so no account picker and our phone number doesn't belong on it
binding.tilAccount.visibility = View.GONE
binding.layoutIncludePhone.visibility = View.GONE
binding.switchIncludePhone.isChecked = false
scheduleGenerate()
} else {
setupDropdown()
}
binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.etReference.addTextChangedListener { scheduleGenerate() }
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
@@ -119,8 +139,8 @@ class PayMvQrFragment : Fragment() {
}
private suspend fun generateQr() {
val account = selectedAccount ?: return
val acquirer = when (account.bank) {
val target = currentTarget() ?: return
val acquirer = when (target.bank) {
"BML" -> "MALBMVMV"
"MIB" -> "MADVMVMV"
"FAHIPAY" -> "FAHIMVMV"
@@ -133,10 +153,10 @@ class PayMvQrFragment : Fragment() {
?.let { "%.2f".format(it) }
val ctx = requireContext()
val includePhone = binding.switchIncludePhone.isChecked
val account = selectedAccount
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val store = CredentialStore(ctx)
val mobile = if (includePhone) {
when (account.bank) {
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
@@ -154,8 +174,8 @@ class PayMvQrFragment : Fragment() {
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, account, payload, amountFormatted)
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, target, payload, amountFormatted)
}
if (_binding == null) return
generatedBitmap = bmp
@@ -224,7 +244,7 @@ class PayMvQrFragment : Fragment() {
private fun renderQrCard(
ctx: Context,
account: BankAccount,
target: QrTarget,
qrPayload: String,
amountStr: String?
): Bitmap {
@@ -246,7 +266,7 @@ class PayMvQrFragment : Fragment() {
canvas.drawColor(Color.WHITE)
// --- Bank logo top-left ---
val logoRes = when (account.bank) {
val logoRes = when (target.bank) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long
@@ -279,7 +299,7 @@ class PayMvQrFragment : Fragment() {
paint.color = Color.WHITE
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
val nameText = account.accountBriefName.uppercase()
val nameText = target.name.uppercase()
paint.textSize = 36f
val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) {
@@ -338,13 +358,13 @@ class PayMvQrFragment : Fragment() {
private fun shareQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
val target = currentTarget() ?: return
lifecycleScope.launch {
val uri = withContext(Dispatchers.IO) {
try {
val dir = File(requireContext().cacheDir, "qr")
dir.mkdirs()
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
val file = File(dir, "${safeName}_paymv_qr.png")
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
FileProvider.getUriForFile(
@@ -366,11 +386,11 @@ class PayMvQrFragment : Fragment() {
private fun saveQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
val target = currentTarget() ?: return
lifecycleScope.launch {
val saved = withContext(Dispatchers.IO) {
try {
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
val filename = "${safeName}_PayMV_QR.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
@@ -412,6 +432,21 @@ class PayMvQrFragment : Fragment() {
_binding = null
}
companion object {
private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_ACCOUNT_NAME = "account_name"
private const val ARG_BANK = "bank"
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT_NUMBER, accountNumber)
putString(ARG_ACCOUNT_NAME, name)
putString(ARG_BANK, bank)
}
}
}
// ── Account dropdown adapter ──────────────────────────────────────────────
private inner class QrAccountAdapter(
@@ -115,7 +115,7 @@ class TransferFragment : Fragment() {
clearForm()
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
showReceipt(receipt, avatar)
}
).also { bmlHandler = it }
@@ -155,7 +155,7 @@ class TransferFragment : Fragment() {
clearForm()
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
showReceipt(receipt, avatar)
}
).also { mfaisaHandler = it }
@@ -245,6 +245,10 @@ 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_RETURN_ON_SUCCESS = "return_on_success"
/** 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) }
@@ -282,7 +286,8 @@ class TransferFragment : Fragment() {
displayName: String,
amount: String?,
remarks: String?,
fromAccountNumber: String? = null
fromAccountNumber: String? = null,
returnOnSuccess: Boolean = false
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
@@ -292,10 +297,28 @@ 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)
}
}
}
/**
* 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?) {
val activity = requireActivity() as HomeActivity
if (arguments?.getBoolean(ARG_RETURN_ON_SUCCESS) == true) {
activity.supportFragmentManager.setFragmentResult(RESULT_TRANSFER_DONE, Bundle())
// Hosted in a sheet (e.g. over a chat): close the sheet; otherwise leave this page.
val sheet = parentFragment as? androidx.fragment.app.DialogFragment
if (sheet != null) sheet.dismiss() else parentFragmentManager.popBackStack()
} else {
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferBinding.inflate(inflater, container, false)
return binding.root
@@ -1205,7 +1228,7 @@ class TransferFragment : Fragment() {
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
dialog.dismiss()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
showReceipt(receipt, capturedToAvatar)
} else if (!ok) {
dialog.dismiss()
if (msg == "CONNECTIVITY") {
@@ -0,0 +1,60 @@
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.
@Suppress("DEPRECATION")
d.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
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 }
}
}
@@ -212,6 +212,7 @@ class CredentialsFragment : Fragment() {
val remaining = 30 - secondsInPeriod
binding.tvOtpCode.text = otp
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
binding.otpTimer.max = 30
binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE
@@ -0,0 +1,85 @@
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
/** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */
private const val MAX_LOOKUPS_PER_RUN = 20
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] that are unknown or stale. Network I/O — call on Dispatchers.IO. */
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
val now = System.currentTimeMillis()
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(MAX_LOOKUPS_PER_RUN)
if (due.isEmpty()) return
val client = BmlValidateClient()
// null = network error: leave the account due for next time. A failed lookup is stored blank.
val found = due.associateWith { account ->
try {
val v = client.validateAccount(session, account)
Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now)
} catch (_: Exception) { null }
}
synchronized(lock) {
val current = load(context).toMutableMap()
found.forEach { (account, entry) -> if (entry != null) current[account] = entry }
save(context, current)
}
}
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
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,432 @@
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 isSent get() = amount < 0
/** "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>"
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 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, 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 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")
/** "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 (.+)""")
/** Masked own account 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 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() }
fun keyOf(m: ChatMessage): String {
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 }
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
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
// SWIFT (foreign) beneficiaries can't be looked up through BML.
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
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()
if (description !in TRANSFER_TYPES || 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
)
}
/**
* 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 (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) }
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
)
}
/** 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 }
)
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
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
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
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() }
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)
)
}
)
} 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)
})
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
} catch (_: Exception) {}
}
}
@@ -13,6 +13,8 @@ data class ContactDisplay(
val network: TransferNetwork,
val bankColor: String,
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
val bankName: String?, // shown in the contact details sheet; null = hide row
val currency: String?, // shown in the contact details sheet; null = hide row
val imageHash: String?,
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
@@ -9,10 +9,11 @@ object Totp {
/**
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
* [periodOffset] shifts the time window, e.g. 1 yields the next code.
*/
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String {
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30, periodOffset: Long = 0): String {
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
val counter = System.currentTimeMillis() / 1000L / periodSeconds
val counter = System.currentTimeMillis() / 1000L / periodSeconds + periodOffset
val otp = hotp(key, counter, digits)
return otp.toString().padStart(digits, '0')
}
@@ -15,6 +15,8 @@ object BmlContactParser {
network = TransferNetwork.BML,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
@@ -15,6 +15,8 @@ object FahipayContactParser {
network = TransferNetwork.FAHIPAY,
bankColor = contact.bankColor,
detail = null, // Fahipay contacts show no detail line
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
@@ -21,6 +21,8 @@ object MibContactParser {
network = network,
bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="8dp" />
<solid android:color="?attr/colorSurfaceContainerHigh" />
<stroke android:width="1dp" android:color="?attr/colorOutlineVariant" />
</shape>
+9
View File
@@ -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>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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/black"
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.61 -4.7,-4.2z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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/black"
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5zM3,21h8v-8H3V21zM5,15h4v4H5V15zM13,3v8h8V3H13zM19,9h-4V5h4V9zM19,19h2v2h-2V19zM13,13h2v2h-2V13zM15,15h2v2h-2V15zM13,17h2v2h-2V17zM15,19h2v2h-2V19zM17,17h2v2h-2V17zM17,13h2v2h-2V13zM19,15h2v2h-2V15z" />
</vector>
+9
View File
@@ -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>
+227
View File
@@ -0,0 +1,227 @@
<?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/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_date"
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>
<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:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:layout_marginStart="4dp"
android:text="@string/chat_from_account"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardFromAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="12dp">
<include
android:id="@+id/fromAccountRow"
layout="@layout/item_account_dropdown"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:rotation="90"
android:src="@drawable/ic_arrow_right"
android:importantForAccessibility="no" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/chat_amount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
app:boxCornerRadiusTopStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusBottomEnd="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberDecimal"
android:maxLines="1"
android:imeOptions="actionSend" />
</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>
</LinearLayout>
@@ -0,0 +1,66 @@
<?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">
<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>
@@ -175,6 +175,32 @@
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:orientation="vertical"
android:gravity="end"
android:alpha="0.7">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Next"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvNextOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.1"
android:fontFamily="monospace" />
</LinearLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpTimer"
android:layout_width="32dp"
+33 -4
View File
@@ -1,15 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingTop="16dp"
android:paddingBottom="16dp"
android:clipToPadding="false" />
</LinearLayout>
<LinearLayout
android:id="@+id/emptyState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center_horizontal"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<ImageView
android:layout_width="56dp"
android:layout_height="56dp"
android:layout_marginBottom="12dp"
android:alpha="0.6"
android:importantForAccessibility="no"
android:src="@drawable/ic_nav_otp"
android:tint="?attr/colorOnSurfaceVariant" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:text="No OTP codes yet.\nAdd an MIB or BML login to see its codes here."
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</FrameLayout>
@@ -95,6 +95,7 @@
<!-- Include phone number toggle -->
<LinearLayout
android:id="@+id/layoutIncludePhone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
+62
View File
@@ -0,0 +1,62 @@
<?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_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/tvName"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,100 @@
<?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="4dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="200dp"
app:cardCornerRadius="18dp"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="200dp"
android:orientation="vertical"
android:padding="14dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvLabel"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:maxLines="1"
android:ellipsize="end" />
<ImageButton
android:id="@+id/btnReceipt"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="6dp"
android:padding="6dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/chat_view_receipt"
android:scaleType="fitCenter"
android:src="@drawable/ic_receipt"
android:visibility="gone" />
<TextView
android:id="@+id/tvBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="8dp"
android:paddingVertical="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall" />
</LinearLayout>
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceHeadlineSmall" />
<TextView
android:id="@+id/tvAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:alpha="0.8"
android:textAppearance="?attr/textAppearanceBodySmall" />
<TextView
android:id="@+id/tvNote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textAppearance="?attr/textAppearanceBodyMedium" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tvMeta"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:layout_marginHorizontal="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
@@ -0,0 +1,19 @@
<?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:background="@drawable/bg_chat_date"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</FrameLayout>
+1 -19
View File
@@ -4,6 +4,7 @@
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">
@@ -26,25 +27,6 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnEditContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_edit"
android:padding="6dp"
android:contentDescription="@string/contact_edit" />
<ImageButton
android:id="@+id/btnDeleteContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_delete"
android:padding="6dp"
android:tint="?attr/colorError"
android:contentDescription="@string/contact_delete" />
<ImageButton
android:id="@+id/btnTransferContact"
android:layout_width="36dp"
+130 -22
View File
@@ -2,56 +2,164 @@
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardElevation="2dp">
app:cardCornerRadius="24dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
android:paddingHorizontal="20dp"
android:paddingTop="16dp"
android:paddingBottom="12dp">
<!-- Header: logo, bank + holder name, countdown ring -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivBankLogo"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
android:scaleType="fitCenter"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvOtpBank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvOtpLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvOtpCode"
</LinearLayout>
<FrameLayout
android:layout_width="40dp"
android:layout_height="40dp">
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpProgress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceDisplaySmall"
android:textColor="?attr/colorPrimary"
android:fontFamily="monospace"
android:letterSpacing="0.15"
android:layout_marginBottom="16dp" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/otpProgress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:indeterminate="false"
android:max="30"
app:trackCornerRadius="4dp"
app:indicatorSize="40dp"
app:trackThickness="3dp"
app:trackCornerRadius="2dp"
app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" />
app:trackColor="?attr/colorOutlineVariant" />
<TextView
android:id="@+id/tvOtpCountdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:layout_gravity="center"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout>
</LinearLayout>
<!-- Current code + copy -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="monospace"
android:letterSpacing="0.12"
android:textAppearance="?attr/textAppearanceDisplaySmall"
android:textColor="?attr/colorPrimary"
android:textStyle="bold" />
<Button
android:id="@+id/btnCopyOtp"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Copy OTP"
android:tooltipText="Copy OTP"
app:icon="@drawable/ic_copy" />
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp" />
<!-- Next code + copy -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="Next"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvNextOtpCode"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:fontFamily="monospace"
android:letterSpacing="0.1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<Button
android:id="@+id/btnCopyNextOtp"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="Copy next OTP"
android:tooltipText="Copy next OTP"
app:icon="@drawable/ic_copy"
app:iconTint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -0,0 +1,448 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView
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:paddingBottom="24dp">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivPhoto"
android:layout_width="112dp"
android:layout_height="112dp"
android:layout_gravity="center_horizontal"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_horizontal"
android:paddingHorizontal="24dp"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<LinearLayout
android:id="@+id/actionRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal"
android:paddingHorizontal="16dp">
<LinearLayout
android:id="@+id/actionTransfer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnTransfer"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/transfer"
app:icon="@drawable/ic_send"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/transfer"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionQr"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnQr"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/contact_qr"
app:icon="@drawable/ic_qr_code"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:id="@+id/tvQrLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_qr"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionEdit"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnEdit"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/contact_edit"
app:icon="@drawable/ic_edit"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_edit"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurface" />
</LinearLayout>
<LinearLayout
android:id="@+id/actionDelete"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center_horizontal"
android:orientation="vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDelete"
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
android:layout_width="56dp"
android:layout_height="56dp"
android:contentDescription="@string/contact_delete"
app:backgroundTint="?attr/colorErrorContainer"
app:icon="@drawable/ic_delete"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
android:padding="0dp"
app:iconGravity="textStart"
app:iconPadding="0dp"
app:iconSize="24dp"
app:iconTint="?attr/colorOnErrorContainer" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:text="@string/contact_delete"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorError" />
</LinearLayout>
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_manage_card"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_account_number"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<ImageButton
android:id="@+id/btnShareAccount"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/contact_share_account"
android:padding="8dp"
android:src="@drawable/ic_share"
app:tint="?attr/colorOnSurfaceVariant" />
<ImageButton
android:id="@+id/btnCopyAccount"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/contact_copy_account"
android:padding="8dp"
android:src="@drawable/ic_copy"
app:tint="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowRealName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_contacts"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvRealName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_account_name"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/rowCurrency"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_currency"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvCurrency"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_currency"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/rowBank"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:minHeight="64dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/ivBankIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_nav_finances"
app:tint="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvBank"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="false" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/contact_bank"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/sourceSection"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="56dp"
android:orientation="horizontal"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/ivSourceIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSource"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSourceBank"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</androidx.core.widget.NestedScrollView>
@@ -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>
+3 -3
View File
@@ -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" />
+3
View File
@@ -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" />
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="action_open_contacts" type="id" />
</resources>
+42
View File
@@ -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>
@@ -332,6 +334,14 @@
<string name="contact_delete_message">Remove %s from your contacts?</string>
<string name="contact_deleted">Contact deleted</string>
<string name="contact_delete_failed">Could not delete contact</string>
<string name="contact_account_number">Account number</string>
<string name="contact_account_name">Account name</string>
<string name="contact_bank">Bank</string>
<string name="contact_qr">QR</string>
<string name="contact_delete_warning">This can\'t be undone.</string>
<string name="contact_copy_account">Copy account number</string>
<string name="contact_share_account">Share account details</string>
<string name="contact_account_copied">Account number copied</string>
<!-- Financing -->
<string name="financing_empty">No financing deals found</string>
@@ -392,4 +402,36 @@
<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_from_label">From %s</string>
<string name="chat_received_label">Received</string>
<string name="chat_sent_label">Sent</string>
<string name="chat_into_label">To %s</string>
<string name="chat_debit">Debit</string>
<string name="chat_credit">Credit</string>
<string name="chat_sent_meta">✓ Sent · %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_transfer_sent">Transfer 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_from_to_label">From %1$s → %2$s</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_account_subtitle">Account %s</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>
</resources>
+10
View File
@@ -1,6 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
<!-- Destructive confirmation dialog: centered icon/title, filled red confirm button -->
<style name="ThemeOverlay.BasedBank.DestructiveDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
<item name="buttonBarPositiveButtonStyle">@style/Widget.BasedBank.DestructiveDialogButton</item>
</style>
<style name="Widget.BasedBank.DestructiveDialogButton" parent="Widget.Material3.Button">
<item name="backgroundTint">?attr/colorError</item>
<item name="android:textColor">?attr/colorOnError</item>
</style>
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/seed_primary</item>
<item name="colorSecondary">@color/seed_secondary</item>
+11 -8
View File
@@ -13,10 +13,13 @@ Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed a
## TOTP Display
Each card shows:
- Bank logo and name
- The current 6-digit TOTP code (large text)
- A circular countdown ring showing time remaining in the current 30-second window
- The code refreshes automatically when the window expires — no user interaction needed
- Bank logo, bank name and account holder name
- A circular countdown ring with the seconds left in the current 30-second window; the ring and code turn red in the last 5 seconds
- The current 6-digit TOTP code (large text) with a copy button
- The next window's code (smaller, below a divider) with its own copy button — handy when the current code is about to expire
- The codes refresh automatically when the window expires — no user interaction needed
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
### Algorithm
@@ -32,12 +35,12 @@ Standard RFC 6238 TOTP:
One card is rendered for every MIB and every BML login that has a stored OTP seed (`OtpFragment.kt`), sorted by the user's [login order](00-app-overview.md#login-order). Seeds are per-`loginId` in `CredentialStore`.
| Bank | Seed source | Card label |
| Bank | Seed source | Card title / subtitle |
|---|---|---|
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `"MIB · {fullName}"` |
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {fullName}"` |
| MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{fullName}` |
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `BML` / `{fullName}` |
If no full name has been cached the label falls back to plain `"MIB"` / `"BML"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
If no full name has been cached the subtitle falls back to `"Authenticator"` and a background `MibProfileClient.fetchPersonalProfile()` / `BmlAccountClient.fetchUserInfo()` call refreshes it.
---
@@ -0,0 +1 @@
- Support for BML payment gateway using PayMV QR
@@ -0,0 +1,5 @@
- NFC tap-to-pay with wallet integration
- Set a default card for NFC payments
- Launcher shortcut renamed to "Scan to Pay"
- New NFC icon
- Cards hidden when not relevant
@@ -0,0 +1,9 @@
- Default transfer accounts
- Share-to-scan-to-pay with merchant details
- Static BML QR scans saved to recents
- Unified QR scan flow across banks
- Friendlier transfer flow UI
- Removed *** masking on PayMV QR amounts
- New NFC tap-to-pay animation
- Fixed NFC-related crash
- Scanning a PayMV QR after selecting a card falls back to the transfer flow
@@ -0,0 +1,2 @@
- Fixed transfer form resetting on switching tabs
- Fixed default account not selected from contacts page or contact picker
@@ -0,0 +1 @@
- New navigation mode: Nostalgic
@@ -0,0 +1,10 @@
- View details of blocked balance
- Notifications
- Wheel optimizations
- Fixed back navigation from Finances page
- Fixed "An error occurred" instead of "No available balance"
- Fixed Islamic visa card image
- Fixed PIN button glyph scaling and theme bugs
- Fixed receipt buttons on some phones
- Fixed balance not updating after BML QR payment
- Fixed onboarding warning bypass
@@ -0,0 +1 @@
- NFC prompts for off, default and not supported states
@@ -0,0 +1,3 @@
- About page
- Donate to support development
- Import TOTP QR and Google Authenticator export QR
@@ -0,0 +1,6 @@
- BML and MIB card freeze/unfreeze
- Background service and push notifications (very unstable)
- Cards ordered: active BML, MIB, inactive BML, inactive MIB
- Faster notification loading
- Fixed notification icon in light theme
- Fixed contact picker rendering
@@ -0,0 +1,9 @@
- Edge-to-edge support for Android 15+
- Redesigned onboarding pages and added disclaimer
- otpauth://totp/ URI import for OTP seed
- Hint shown when biometrics are disabled
- App resumes last screen after unlocking
- Loading indicators for account info and transfers
- Fixed bottom bar staying above the keyboard
- Fixed adding a contact with multiple BML logins
- Added Visa, Master and Amex card icons
@@ -0,0 +1,8 @@
- Contact picker recents show most recent first
- Outstanding and unbilled credit card values
- Fixed MIB USD history amounts
- Fixed currency detection in MIB to MIB transfers
- Option to add contact during BML to MIB USD transfer
- Settings item renamed to "Confirm Transaction"
- Fixed "Cards" title in tap to pay from shortcut
- Removed gap under bottom bar on cards screens
@@ -0,0 +1 @@
- Initial support for Ooredoo M-Faisa
@@ -0,0 +1,4 @@
- Updated BML card mapping (fixed Mastercard Platinum)
- Disabled PayMV QR for M-Faisa accounts
- Fixed share and save buttons showing on shared receipt image
- Fixed bottom bar flashing before receipt share sheet
@@ -0,0 +1,5 @@
- Fixed QR scanner failing to launch on revisit
- Fixed PayMV scan keeping unusable M-Faisa source
- Fixed BML merchant staying loaded after payment
- Transfer logic split into per-bank handlers
- Note: Fahipay is still broken
@@ -0,0 +1 @@
- No changes from previous version
@@ -0,0 +1,7 @@
- Hide accounts you don't want to see
- Reorder logins
- Account number field on the transfer page suggests saved contacts
- Transfer page shows the other account's currency
- Search button on the transfer "To" field keyboard, focus moves to amount afterwards
- Search key in the add contact drawer now works
- Saving a profile disable no longer sends unnecessary refresh requests
@@ -0,0 +1,5 @@
- Redesigned OTP page: codes turn red in the last 5 seconds and there are copy buttons, and you can tap a card to copy its code
- OTP page and login setup now also show the next code
- Redesigned contacts list: tap a contact to open a details sheet, which now holds the edit and delete buttons
- Contact details show the account number, account name, bank and currency
- Copy or share a contact's account details, or show a PayMV QR code for them
@@ -0,0 +1,5 @@
- Customizable bottom bar and quick actions
- Multiple Fahipay and MIB account support
- Updated BML logo
- Transfer button stays disabled until all required fields are filled
- Fixed display issues on phones without edge-to-edge screens
@@ -0,0 +1,8 @@
- BML business profile support
- Privacy mode toggle to hide balances
- View previous transfer receipts, full screen view, long-press to copy, save and share
- Initial PayMV QR generation
- Renamed Activities to Recent Transfers
- Fixed card transaction history not loading
- Fixed BML contact list loading
- Fixed single-profile multi-login issue
@@ -0,0 +1,5 @@
- BML loans shown on the Financing page, included in pending finances
- Animated lock and show/hide amount icons
- Credit cards and spending limits in a separate dashboard section, limits shown as progress bars
- Optimized MIB session keepalive
- Long-press to copy receipt fields in full screen
@@ -0,0 +1,8 @@
- MIB debit/credit cards shown in a dashboard carousel
- Transfers from BML business profile accounts
- New setting: auto unlock on correct PIN
- Fewer network requests on BML session refresh
- Refresh indicator moved to the action bar
- Reworked back button navigation
- Fixed PayMV QR page empty space
- BML loan accounts excluded from balance total and transfer sources
@@ -0,0 +1,7 @@
- Compressed MIB card images to reduce app size
- Connectivity banners for network issues
- Transfer button disabled when source bank is unavailable
- Fixed cache read issue when refreshing offline
- Privacy mode hides amounts in transfer dropdown
- User-agent reports real device info
- Fixed error on failed PIN entry
@@ -0,0 +1,8 @@
- QR payments from BML gateway and static QR payments from BML cards
- Zoom and flashlight on QR scanner
- Bank/profile images in accounts list and transfer dropdown
- Local per-profile images for BML and Fahipay
- Better network error handling and timeout handling
- Fixed contacts infinite loading offline
- Fixed crash with no network on transaction history
- Fixed nav bar buttons disappearing
@@ -0,0 +1,10 @@
- Manage cards mode and default payment card
- Theme customizations
- Launcher shortcuts
- Tap to copy OTP
- Unified card settings and Pay with Card page
- Dashboard shows MVR and USD blocked funds separately
- Accounts list uses available balance
- Fixed lockscreen bypass on rooted devices
- Fixed empty dashboard without accounts
- PayMV QR generation fix
@@ -0,0 +1,31 @@
Thijooree is an unofficial, unified banking app for the Maldives, built by reverse engineering the official bank apps.
Supported services:
- Bank of Maldives (BML)
- Maldives Islamic Bank (MIB)
- Fahipay (initial support)
- Ooredoo M-Faisa (initial support)
Features:
- Multiple logins per bank in one app, with a unified dashboard of all your accounts
- Transfers between accounts and banks, with saved contacts and Favara IDs
- Transaction history for accounts
- Cards: view BML and MIB cards, freeze/unfreeze
- NFC tap-to-pay
- QR payments: scan PayMV and BML QR codes, generate your own PayMV QR, with zoom and flashlight on the scanner
- Loans, credit cards, spending limits and blocked funds on the Financing page
- Built-in TOTP: import from otpauth:// URIs, QR codes or Google Authenticator exports
- Notifications
- Privacy mode to hide balances
- PIN and biometric app lock
- Customizable bottom bar, quick actions and themes
- Hide specific accounts per profile
Requirements:
- Your banking credentials for each bank
Privacy:
No data ever leaves your device except the API calls to the banking services themselves.
Disclaimer:
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by BML, MIB, Fahipay or Ooredoo. Use at your own risk. Review the source code before entering your banking credentials.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -0,0 +1 @@
Unified banking app for Maldives from reverse enginered official bank apps.
@@ -0,0 +1 @@
Thijooree