Compare commits

...
6 Commits
Author SHA1 Message Date
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
58 changed files with 1247 additions and 155 deletions
+13 -16
View File
@@ -27,24 +27,21 @@ jobs:
- name: Rename APK - name: Rename APK
run: | run: |
APP_NAME="${{ gitea.repository }}" APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}" APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_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" find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
- name: Extract release notes - name: Extract release notes
run: | 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 echo "No release notes" > release_notes.md
# VERSION="${{ gitea.ref_name }}" fi
# 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
- name: Create Gitea Release - name: Create Gitea Release
env: env:
@@ -53,7 +50,7 @@ jobs:
GITEA_TOKEN: ${{ secrets.PAT_GITEA }} GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
run: | run: |
APP_NAME="${{ gitea.repository }}" APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}" APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
TITLE="${APP_NAME} ${TAG}" TITLE="${APP_NAME} ${TAG}"
NOTES_FILE="release_notes.md" NOTES_FILE="release_notes.md"
@@ -101,12 +98,12 @@ jobs:
fi fi
APP_NAME="${{ gitea.repository }}" APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}" APP_NAME="${APP_NAME//\//-}"
TAG="${{ gitea.ref_name }}" TAG="${{ gitea.ref_name }}"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk" 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" \ curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
-F "chat_id=${TG_CHAT_ID}" \ -F "chat_id=${TG_CHAT_ID}" \
-F "document=@${ASSET_PATH}" \ -F "document=@${ASSET_PATH}" \
-F "caption=${CAPTION}" --form-string "caption=${CAPTION}"
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 27 versionCode = 28
versionName = "1.0.26" versionName = "1.0.27"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -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 row in [ContactsFragment] is tapped. */
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetContactDetailsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val contactsFragment get() = parentFragment as? ContactsFragment
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)
}
}
}
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemContactBinding import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter( class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>, private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit, private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (ContactDisplay) -> Unit, private val onContactClick: (ContactDisplay) -> Unit,
private val onTransferClick: (ContactDisplay) -> Unit private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
@@ -66,12 +65,9 @@ class ContactsAdapter(
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos]) if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
} }
binding.btnEditContact.setOnClickListener { binding.root.setOnClickListener {
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnDeleteContact.setOnClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos]) if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
} }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
@@ -107,24 +103,22 @@ class ContactsAdapter(
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility = binding.btnTransferContact.visibility =
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE 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) { if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo) binding.ivContactPhoto.setImageBitmap(photo)
} else { } else {
binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.name, contact.bankColor)
)
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = binding.ivContactPhoto.context.resources val sizePx = binding.ivContactPhoto.context.resources
.getDimensionPixelSize(android.R.dimen.app_icon_size) .getDimensionPixelSize(android.R.dimen.app_icon_size)
.coerceAtLeast(96) .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 bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888) val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm) val canvas = Canvas(bm)
@@ -138,6 +132,4 @@ class ContactsAdapter(
val metrics = paint.fontMetrics val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint) canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm return bm
}
}
} }
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory 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.os.Bundle
import android.util.Base64 import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
ContactsAdapter( ContactsAdapter(
imageCache = sharedImageCache, imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) }, onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) }, onContactClick = { contact -> showDetails(contact) },
onTransferClick = { contact -> openTransfer(contact) } onTransferClick = { contact -> openTransfer(contact) }
).also { a -> ).also { a ->
a.setFilter(page.categoryId, currentSearch) a.setFilter(page.categoryId, currentSearch)
@@ -170,7 +177,12 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false) 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")
}
internal fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance( val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber, accountNumber = contact.accountNumber,
displayName = contact.name, displayName = contact.name,
@@ -181,10 +193,26 @@ class ContactsFragment : Fragment() {
(requireActivity() as HomeActivity).showWithBackStack(fragment) (requireActivity() as HomeActivity).showWithBackStack(fragment)
} }
private fun confirmDelete(contact: ContactDisplay) { internal fun confirmDelete(contact: ContactDisplay) {
MaterialAlertDialogBuilder(requireContext()) 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) .setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.name)) .setMessage(message)
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) } .setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
@@ -18,7 +18,9 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.mib.MibProfileClient import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
@@ -32,7 +34,7 @@ class OtpFragment : Fragment() {
private var _binding: FragmentOtpBinding? = null private var _binding: FragmentOtpBinding? = null
private val binding get() = _binding!! 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>) : private inner class OtpAdapter(private val entries: List<OtpEntry>) :
RecyclerView.Adapter<OtpAdapter.VH>() { RecyclerView.Adapter<OtpAdapter.VH>() {
@@ -45,18 +47,17 @@ class OtpFragment : Fragment() {
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)) VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
override fun onBindViewHolder(holder: VH, position: Int) { override fun onBindViewHolder(holder: VH, position: Int) {
holder.b.tvOtpLabel.text = entries[position].label val entry = entries[position]
update(holder.b, entries[position].seed) val b = holder.b
holder.b.root.setOnClickListener { b.tvOtpBank.text = entry.bank
val code = holder.b.tvOtpCode.text.toString().replace(" ", "") b.tvOtpLabel.text = entry.name ?: "Authenticator"
if (code.isNotEmpty()) { b.ivBankLogo.setImageResource(
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code)) )
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { update(b, entry.seed)
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show() 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() { fun tick() {
@@ -71,9 +72,32 @@ class OtpFragment : Fragment() {
val secondsInPeriod = (epochSeconds % 30).toInt() val secondsInPeriod = (epochSeconds % 30).toInt()
val remaining = 30 - secondsInPeriod val remaining = 30 - secondsInPeriod
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" } 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.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
b.otpProgress.progress = remaining 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()) { for (loginId in store.getMibLoginIds()) {
val creds = store.loadMibCredentials(loginId) ?: continue val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId) 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()) { for (loginId in store.getBmlLoginIds()) {
val creds = store.loadBmlCredentials(loginId) ?: continue val creds = store.loadBmlCredentials(loginId) ?: continue
val name = store.loadBmlUserProfile(loginId)?.fullName 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 entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
val adapter = OtpAdapter(entries) val adapter = OtpAdapter(entries)
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter 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 // Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -124,7 +149,7 @@ class OtpFragment : Fragment() {
)) ))
val seed = store.loadMibCredentials(loginId)?.otpSeed val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed } 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 seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed } 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 val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null 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 generatedBitmap: Bitmap? = null
private var generateJob: Job? = null private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
@@ -59,7 +61,17 @@ class PayMvQrFragment : Fragment() {
return binding.root 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?) { 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 val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation) val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
@@ -68,7 +80,15 @@ class PayMvQrFragment : Fragment() {
v.updatePadding(bottom = basePaddingBottom + navBarBottom) v.updatePadding(bottom = basePaddingBottom + navBarBottom)
insets 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() setupDropdown()
}
binding.etAmount.addTextChangedListener { scheduleGenerate() } binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.etReference.addTextChangedListener { scheduleGenerate() } binding.etReference.addTextChangedListener { scheduleGenerate() }
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() } binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
@@ -119,8 +139,8 @@ class PayMvQrFragment : Fragment() {
} }
private suspend fun generateQr() { private suspend fun generateQr() {
val account = selectedAccount ?: return val target = currentTarget() ?: return
val acquirer = when (account.bank) { val acquirer = when (target.bank) {
"BML" -> "MALBMVMV" "BML" -> "MALBMVMV"
"MIB" -> "MADVMVMV" "MIB" -> "MADVMVMV"
"FAHIPAY" -> "FAHIMVMV" "FAHIPAY" -> "FAHIMVMV"
@@ -133,10 +153,10 @@ class PayMvQrFragment : Fragment() {
?.let { "%.2f".format(it) } ?.let { "%.2f".format(it) }
val ctx = requireContext() 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 loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val store = CredentialStore(ctx) val store = CredentialStore(ctx)
val mobile = if (includePhone) {
when (account.bank) { when (account.bank) {
"BML" -> store.loadBmlUserProfile(loginId)?.mobile "BML" -> store.loadBmlUserProfile(loginId)?.mobile
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile "FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
@@ -154,8 +174,8 @@ class PayMvQrFragment : Fragment() {
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default) ?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
val bmp = withContext(Dispatchers.Default) { val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose) val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, account, payload, amountFormatted) renderQrCard(ctx, target, payload, amountFormatted)
} }
if (_binding == null) return if (_binding == null) return
generatedBitmap = bmp generatedBitmap = bmp
@@ -224,7 +244,7 @@ class PayMvQrFragment : Fragment() {
private fun renderQrCard( private fun renderQrCard(
ctx: Context, ctx: Context,
account: BankAccount, target: QrTarget,
qrPayload: String, qrPayload: String,
amountStr: String? amountStr: String?
): Bitmap { ): Bitmap {
@@ -246,7 +266,7 @@ class PayMvQrFragment : Fragment() {
canvas.drawColor(Color.WHITE) canvas.drawColor(Color.WHITE)
// --- Bank logo top-left --- // --- Bank logo top-left ---
val logoRes = when (account.bank) { val logoRes = when (target.bank) {
"BML" -> R.drawable.bml_logo_vector "BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo "MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long else -> R.drawable.fahipay_logo_long
@@ -279,7 +299,7 @@ class PayMvQrFragment : Fragment() {
paint.color = Color.WHITE paint.color = Color.WHITE
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER paint.textAlign = Paint.Align.CENTER
val nameText = account.accountBriefName.uppercase() val nameText = target.name.uppercase()
paint.textSize = 36f paint.textSize = 36f
val maxNameW = boxR - boxL - 48f val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) { if (paint.measureText(nameText) > maxNameW) {
@@ -338,13 +358,13 @@ class PayMvQrFragment : Fragment() {
private fun shareQr() { private fun shareQr() {
val bmp = generatedBitmap ?: return val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return val target = currentTarget() ?: return
lifecycleScope.launch { lifecycleScope.launch {
val uri = withContext(Dispatchers.IO) { val uri = withContext(Dispatchers.IO) {
try { try {
val dir = File(requireContext().cacheDir, "qr") val dir = File(requireContext().cacheDir, "qr")
dir.mkdirs() 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") val file = File(dir, "${safeName}_paymv_qr.png")
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) } FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
FileProvider.getUriForFile( FileProvider.getUriForFile(
@@ -366,11 +386,11 @@ class PayMvQrFragment : Fragment() {
private fun saveQr() { private fun saveQr() {
val bmp = generatedBitmap ?: return val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return val target = currentTarget() ?: return
lifecycleScope.launch { lifecycleScope.launch {
val saved = withContext(Dispatchers.IO) { val saved = withContext(Dispatchers.IO) {
try { 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" val filename = "${safeName}_PayMV_QR.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply { val values = ContentValues().apply {
@@ -412,6 +432,21 @@ class PayMvQrFragment : Fragment() {
_binding = null _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 ────────────────────────────────────────────── // ── Account dropdown adapter ──────────────────────────────────────────────
private inner class QrAccountAdapter( private inner class QrAccountAdapter(
@@ -212,6 +212,7 @@ class CredentialsFragment : Fragment() {
val remaining = 30 - secondsInPeriod val remaining = 30 - secondsInPeriod
binding.tvOtpCode.text = otp binding.tvOtpCode.text = otp
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
binding.otpTimer.max = 30 binding.otpTimer.max = 30
binding.otpTimer.progress = remaining binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE binding.cardOtp.visibility = View.VISIBLE
@@ -13,6 +13,8 @@ data class ContactDisplay(
val network: TransferNetwork, val network: TransferNetwork,
val bankColor: String, val bankColor: String,
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row 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 imageHash: String?,
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager) val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen 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). * 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. * 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 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) val otp = hotp(key, counter, digits)
return otp.toString().padStart(digits, '0') return otp.toString().padStart(digits, '0')
} }
@@ -15,6 +15,8 @@ object BmlContactParser {
network = TransferNetwork.BML, network = TransferNetwork.BML,
bankColor = contact.bankColor, bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}", detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash, imageHash = contact.customerImgHash,
profileId = contact.profileId, profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}", transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
@@ -15,6 +15,8 @@ object FahipayContactParser {
network = TransferNetwork.FAHIPAY, network = TransferNetwork.FAHIPAY,
bankColor = contact.bankColor, bankColor = contact.bankColor,
detail = null, // Fahipay contacts show no detail line detail = null, // Fahipay contacts show no detail line
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash, imageHash = contact.customerImgHash,
profileId = contact.profileId, profileId = contact.profileId,
transferSubtitle = contact.benefAccount, transferSubtitle = contact.benefAccount,
@@ -21,6 +21,8 @@ object MibContactParser {
network = network, network = network,
bankColor = contact.bankColor, bankColor = contact.bankColor,
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}", detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
imageHash = contact.customerImgHash, imageHash = contact.customerImgHash,
profileId = contact.profileId, profileId = contact.profileId,
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}", transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
+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>
@@ -175,6 +175,32 @@
</LinearLayout> </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 <com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpTimer" android:id="@+id/otpTimer"
android:layout_width="32dp" android:layout_width="32dp"
+33 -4
View File
@@ -1,15 +1,44 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:orientation="vertical">
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView" android:id="@+id/recyclerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="16dp"
android:clipToPadding="false" /> 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 --> <!-- Include phone number toggle -->
<LinearLayout <LinearLayout
android:id="@+id/layoutIncludePhone"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
+1 -19
View File
@@ -4,6 +4,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
android:paddingVertical="10dp"> android:paddingVertical="10dp">
@@ -26,25 +27,6 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="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 <ImageButton
android:id="@+id/btnTransferContact" android:id="@+id/btnTransferContact"
android:layout_width="36dp" android:layout_width="36dp"
+130 -22
View File
@@ -2,56 +2,164 @@
<com.google.android.material.card.MaterialCardView <com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
app:cardCornerRadius="16dp" app:cardCornerRadius="24dp">
app:cardElevation="2dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" 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 <TextView
android:id="@+id/tvOtpLabel" android:id="@+id/tvOtpLabel"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelMedium" android:ellipsize="end"
android:textColor="?attr/colorOnSurfaceVariant" android:maxLines="1"
android:layout_marginBottom="8dp" /> android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView </LinearLayout>
android:id="@+id/tvOtpCode"
<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_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceDisplaySmall" android:layout_gravity="center"
android:textColor="?attr/colorPrimary" android:indeterminate="false"
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:max="30" android:max="30"
app:trackCornerRadius="4dp" app:indicatorSize="40dp"
app:trackThickness="3dp"
app:trackCornerRadius="2dp"
app:indicatorColor="?attr/colorPrimary" app:indicatorColor="?attr/colorPrimary"
app:trackColor="?attr/colorSurfaceVariant" /> app:trackColor="?attr/colorOutlineVariant" />
<TextView <TextView
android:id="@+id/tvOtpCountdown" android:id="@+id/tvOtpCountdown"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_gravity="center"
android:textAppearance="?attr/textAppearanceLabelSmall" android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" /> 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> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </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>
+8
View File
@@ -332,6 +332,14 @@
<string name="contact_delete_message">Remove %s from your contacts?</string> <string name="contact_delete_message">Remove %s from your contacts?</string>
<string name="contact_deleted">Contact deleted</string> <string name="contact_deleted">Contact deleted</string>
<string name="contact_delete_failed">Could not delete contact</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 --> <!-- Financing -->
<string name="financing_empty">No financing deals found</string> <string name="financing_empty">No financing deals found</string>
+10
View File
@@ -1,6 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" /> <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"> <style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/seed_primary</item> <item name="colorPrimary">@color/seed_primary</item>
<item name="colorSecondary">@color/seed_secondary</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 ## TOTP Display
Each card shows: Each card shows:
- Bank logo and name - Bank logo, bank name and account holder name
- The current 6-digit TOTP code (large text) - 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
- A circular countdown ring showing time remaining in the current 30-second window - The current 6-digit TOTP code (large text) with a copy button
- The code refreshes automatically when the window expires — no user interaction needed - 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 ### 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`. 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}"` | | MIB | `loadMibCredentials(loginId).otpSeed` (entered at login) | `MIB` / `{fullName}` |
| BML | `loadBmlCredentials(loginId).otpSeed` (entered at login) | `"BML · {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