added support for custom per-profile image for BML and Fahipay, MIB works pending
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 7s
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 7s
This commit is contained in:
@@ -39,7 +39,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
.addNetworkInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
.header("User-Agent", "android/1.0")
|
||||
.header("Accept", "application/json")
|
||||
@@ -366,6 +366,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile image via P40.
|
||||
* [imageBase64] is a base64-encoded JPEG string.
|
||||
* Returns the new imageHash on success, or null on failure.
|
||||
*/
|
||||
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
|
||||
val payload = baseData(session, "P40").apply {
|
||||
put("profileId", profile.profileId)
|
||||
put("profileImage", imageBase64)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
if (!resp.optBoolean("success", false)) return null
|
||||
return resp.optString("imageHash").takeIf { it.isNotBlank() }
|
||||
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the profile image via P42.
|
||||
* Returns true on success.
|
||||
*/
|
||||
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
|
||||
val payload = baseData(session, "P42").apply {
|
||||
put("profileId", profile.profileId)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
return resp.optBoolean("success", false)
|
||||
}
|
||||
|
||||
private fun post(body: FormBody): String {
|
||||
val request = Request.Builder()
|
||||
.url(BASE_URL)
|
||||
|
||||
@@ -21,7 +21,9 @@ class AccountsAdapter(
|
||||
accounts: List<BankAccount>,
|
||||
private val onAccountClick: (BankAccount) -> Unit = {},
|
||||
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
||||
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null
|
||||
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
||||
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
||||
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
@@ -141,9 +143,16 @@ class AccountsAdapter(
|
||||
|
||||
val hash = account.profileImageHash
|
||||
boundHash = hash
|
||||
if (account.bank == "MIB" && hash != null && profileImageLoader != null) {
|
||||
profileImageLoader.invoke(hash) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
when {
|
||||
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
|
||||
profileImageLoader.invoke(hash) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
|
||||
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
|
||||
class AccountsFragment : Fragment() {
|
||||
|
||||
@@ -56,6 +57,25 @@ class AccountsFragment : Fragment() {
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
},
|
||||
localProfileImageLoader = { loginTag, profileId, onLoaded ->
|
||||
val cacheKey = "$loginTag|$profileId"
|
||||
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val ctx = requireContext()
|
||||
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
|
||||
} else if (loginTag.startsWith("fahipay_")) {
|
||||
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
} else null
|
||||
}
|
||||
if (bitmap != null) {
|
||||
profileImageCache[cacheKey] = bitmap
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
adapter.onTransferClick = { account ->
|
||||
|
||||
@@ -25,6 +25,7 @@ import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
@@ -237,13 +238,15 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = acc.accountNumber,
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
@@ -267,13 +270,15 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
|
||||
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
|
||||
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = acc.accountNumber,
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (!isActive) acc.statusDesc
|
||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
@@ -310,6 +315,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun fetchImage(hash: String) {
|
||||
if (!pendingHashes.add(hash)) return
|
||||
// Local image keys for BML/Fahipay (prefixed with "local:")
|
||||
if (hash.startsWith("local:")) {
|
||||
val key = hash.removePrefix("local:")
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
|
||||
pendingHashes.remove(hash); return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val sess = session ?: return
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -337,6 +353,16 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
||||
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
||||
|
||||
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
|
||||
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
|
||||
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
|
||||
"FAHIPAY" -> {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
|
||||
@@ -437,12 +437,51 @@ class PayMvQrFragment : Fragment() {
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "BML" -> {
|
||||
b.ivDropdownCardLogo.setImageResource(R.drawable.bml_logo_vector)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.bml_logo_vector)
|
||||
if (acc.profileId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "FAHIPAY" -> {
|
||||
b.ivDropdownCardLogo.setImageResource(R.drawable.fahipay_logo)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.fahipay_logo)
|
||||
if (loginId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "MIB" -> {
|
||||
val hash = acc.profileImageHash
|
||||
|
||||
@@ -2,7 +2,11 @@ package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -10,6 +14,8 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -28,6 +34,7 @@ import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.FinancingCache
|
||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlin.coroutines.resume
|
||||
@@ -38,6 +45,8 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
|
||||
class SettingsLoginsFragment : Fragment() {
|
||||
|
||||
@@ -45,6 +54,248 @@ class SettingsLoginsFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
// ── Profile image picker state ─────────────────────────────────────────────
|
||||
|
||||
private sealed class PendingImageTarget {
|
||||
data class Mib(val loginId: String, val profile: MibProfile) : PendingImageTarget()
|
||||
data class Bml(val profileId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||
data class Fahipay(val loginId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||
}
|
||||
private var pendingImageTarget: PendingImageTarget? = null
|
||||
private var cameraPhotoUri: Uri? = null
|
||||
|
||||
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||
handlePickedImage(bitmap)
|
||||
}
|
||||
|
||||
private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (!success) return@registerForActivityResult
|
||||
val uri = cameraPhotoUri ?: return@registerForActivityResult
|
||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||
handlePickedImage(bitmap)
|
||||
}
|
||||
|
||||
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val ctx = requireContext()
|
||||
val inputStream = ctx.contentResolver.openInputStream(uri) ?: return null
|
||||
val original = BitmapFactory.decodeStream(inputStream)
|
||||
inputStream.close()
|
||||
if (original == null) return null
|
||||
val maxDim = 512
|
||||
val scale = minOf(maxDim.toFloat() / original.width, maxDim.toFloat() / original.height, 1f)
|
||||
if (scale < 1f) {
|
||||
Bitmap.createScaledBitmap(original, (original.width * scale).toInt(), (original.height * scale).toInt(), true)
|
||||
} else original
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun handlePickedImage(bitmap: Bitmap) {
|
||||
val target = pendingImageTarget ?: return
|
||||
when (target) {
|
||||
is PendingImageTarget.Mib -> uploadMibProfileImage(target.loginId, target.profile, bitmap)
|
||||
is PendingImageTarget.Bml -> {
|
||||
ProfileImageStore.save(requireContext(), ProfileImageStore.bmlKey(target.profileId), bitmap)
|
||||
target.refreshImage(bitmap)
|
||||
}
|
||||
is PendingImageTarget.Fahipay -> {
|
||||
ProfileImageStore.save(requireContext(), ProfileImageStore.fahipayKey(target.loginId), bitmap)
|
||||
target.refreshImage(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadMibProfileImage(loginId: String, profile: MibProfile, bitmap: Bitmap) {
|
||||
val ctx = requireContext()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val progress = MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_uploading))
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.mibSessions[loginId] ?: app.anyMibSession() ?: return@withContext null
|
||||
val flow = app.mibLoginFlows[loginId] ?: return@withContext null
|
||||
flow.switchProfile(session, profile)
|
||||
// MIB server enforces a small payload limit (~4KB); scale to 100px max
|
||||
val mibMax = 100
|
||||
val mibScale = minOf(mibMax.toFloat() / bitmap.width, mibMax.toFloat() / bitmap.height, 1f)
|
||||
val mibBitmap = if (mibScale < 1f)
|
||||
Bitmap.createScaledBitmap(bitmap, (bitmap.width * mibScale).toInt(), (bitmap.height * mibScale).toInt(), true)
|
||||
else bitmap
|
||||
val baos = ByteArrayOutputStream()
|
||||
mibBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos)
|
||||
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
flow.uploadProfileImage(session, profile, base64)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
progress.dismiss()
|
||||
if (result == null) {
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_upload_failed))
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.show()
|
||||
} else {
|
||||
clearAllCaches(ctx)
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showImagePickerMenu(anchor: View, target: PendingImageTarget, currentBitmap: Bitmap?) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
|
||||
val items = mutableListOf<Triple<Int, String, () -> Unit>>()
|
||||
items += Triple(R.drawable.ic_image, getString(R.string.profile_image_select)) {
|
||||
pendingImageTarget = target
|
||||
galleryLauncher.launch("image/*")
|
||||
}
|
||||
items += Triple(R.drawable.ic_camera, getString(R.string.profile_image_camera)) {
|
||||
pendingImageTarget = target
|
||||
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
|
||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
|
||||
cameraPhotoUri = uri
|
||||
cameraLauncher.launch(uri)
|
||||
}
|
||||
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
|
||||
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
|
||||
removeProfileImage(ctx, target)
|
||||
}
|
||||
}
|
||||
|
||||
val list = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val vp = (8 * dp).toInt()
|
||||
setPadding(0, vp, 0, vp)
|
||||
}
|
||||
for ((iconRes, label, action) in items) {
|
||||
val iconSize = (24 * dp).toInt()
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
val hp = (24 * dp).toInt(); val vp2 = (12 * dp).toInt()
|
||||
setPadding(hp, vp2, hp, vp2)
|
||||
}
|
||||
val iconColor = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||
val icon = ImageView(ctx).apply {
|
||||
setImageResource(iconRes)
|
||||
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
|
||||
}
|
||||
val tv = TextView(ctx).apply {
|
||||
text = label
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginStart = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
row.addView(icon, LinearLayout.LayoutParams(iconSize, iconSize))
|
||||
row.addView(tv)
|
||||
list.addView(row)
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.profile_image_title)
|
||||
.setView(list)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
val rows = (0 until list.childCount).map { list.getChildAt(it) }
|
||||
rows.forEachIndexed { i, row ->
|
||||
row.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
items[i].third()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSavedImage(ctx: Context, target: PendingImageTarget): Boolean = when (target) {
|
||||
is PendingImageTarget.Mib -> target.profile.customerImage != null
|
||||
is PendingImageTarget.Bml -> ProfileImageStore.exists(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||
is PendingImageTarget.Fahipay -> ProfileImageStore.exists(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||
}
|
||||
|
||||
private fun removeProfileImage(ctx: Context, target: PendingImageTarget) {
|
||||
when (target) {
|
||||
is PendingImageTarget.Mib -> {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val progress = MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_deleting))
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.mibSessions[target.loginId] ?: app.anyMibSession() ?: return@withContext
|
||||
val flow = app.mibLoginFlows[target.loginId] ?: return@withContext
|
||||
flow.switchProfile(session, target.profile)
|
||||
flow.deleteProfileImage(session, target.profile)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
progress.dismiss()
|
||||
clearAllCaches(ctx)
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
is PendingImageTarget.Bml -> {
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||
target.refreshImage(null)
|
||||
}
|
||||
is PendingImageTarget.Fahipay -> {
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||
target.refreshImage(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePencilButton(ctx: Context): ImageView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val size = (32 * dp).toInt()
|
||||
return ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_edit)
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeCircleAvatarView(ctx: Context, sizeDp: Int): ImageView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val size = (sizeDp * dp).toInt()
|
||||
return ImageView(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
clipToOutline = true
|
||||
outlineProvider = object : android.view.ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: android.graphics.Outline) {
|
||||
outline.setOval(0, 0, view.width, view.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAvatarBitmap(iv: ImageView, bitmap: Bitmap?) {
|
||||
if (bitmap != null) {
|
||||
iv.setImageBitmap(bitmap)
|
||||
iv.imageTintList = null
|
||||
} else {
|
||||
iv.setImageResource(R.drawable.ic_image)
|
||||
val color = com.google.android.material.color.MaterialColors.getColor(
|
||||
iv, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||
iv.imageTintList = android.content.res.ColorStateList.valueOf(color)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -101,18 +352,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
showLoginDetails(
|
||||
title = getString(R.string.fahipay_name),
|
||||
details = buildString {
|
||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
|
||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
|
||||
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
|
||||
}.trim(),
|
||||
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
|
||||
)
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,8 +459,21 @@ class SettingsLoginsFragment : Fragment() {
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
val pencil = makePencilButton(ctx).apply {
|
||||
alpha = 0.38f
|
||||
isEnabled = false
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = p.profileId !in hidden
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
android.widget.Toast.makeText(ctx, "Work in progress", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
row.addView(textCol)
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
p to toggle
|
||||
@@ -328,6 +581,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
|
||||
val toggleRows = bmlProfiles.map { p ->
|
||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
setAvatarBitmap(avatarIv, currentBitmap)
|
||||
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
@@ -350,8 +607,24 @@ class SettingsLoginsFragment : Fragment() {
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
val pencil = makePencilButton(ctx)
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = p.profileId !in hidden
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val target = PendingImageTarget.Bml(p.profileId) { newBitmap ->
|
||||
setAvatarBitmap(avatarIv, newBitmap)
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
showImagePickerMenu(pencil, target, cur)
|
||||
}
|
||||
val avatarSize = (36 * dp).toInt()
|
||||
row.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||
row.addView(textCol)
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
p to toggle
|
||||
@@ -615,6 +888,75 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFahipayLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: CredentialStore.FahipayUserProfile?
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||
}
|
||||
scroll.addView(container)
|
||||
|
||||
// Avatar row with pencil
|
||||
val avatarRow = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val avatarSize = (56 * dp).toInt()
|
||||
val avatarIv = makeCircleAvatarView(ctx, 56)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
setAvatarBitmap(avatarIv, currentBitmap)
|
||||
|
||||
val pencil = makePencilButton(ctx)
|
||||
val target = PendingImageTarget.Fahipay(loginId) { newBitmap ->
|
||||
setAvatarBitmap(avatarIv, newBitmap)
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
showImagePickerMenu(pencil, target, cur)
|
||||
}
|
||||
avatarRow.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||
avatarRow.addView(pencil)
|
||||
container.addView(avatarRow)
|
||||
|
||||
// Account info lines
|
||||
listOfNotNull(
|
||||
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
|
||||
profile?.nid?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.fahipay_name))
|
||||
.setView(scroll)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
@@ -654,7 +996,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
// Remove all per-profile sessions for this login from the in-memory map
|
||||
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
||||
profiles.forEach { app.bmlSessions.remove(it.profileId) }
|
||||
profiles.forEach { p ->
|
||||
app.bmlSessions.remove(p.profileId)
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
}
|
||||
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||
store.clearBmlCredentials(loginId)
|
||||
app.bmlProfilesMap.remove(loginId)
|
||||
@@ -669,6 +1014,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
|
||||
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
||||
val ctx = requireContext()
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
store.clearFahipayCredentials(loginId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.fahipaySessions.remove(loginId)
|
||||
@@ -682,5 +1028,6 @@ class SettingsLoginsFragment : Fragment() {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
// Note: ProfileImageStore is intentionally NOT cleared here — profile images are user-set data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1753,12 +1753,51 @@ class TransferFragment : Fragment() {
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "BML" -> {
|
||||
b.ivDropdownCardLogo.setImageResource(R.drawable.bml_logo_vector)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.bml_logo_vector)
|
||||
if (acc.profileId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "FAHIPAY" -> {
|
||||
b.ivDropdownCardLogo.setImageResource(R.drawable.fahipay_logo)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.fahipay_logo)
|
||||
if (loginId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "MIB" -> {
|
||||
val hash = acc.profileImageHash
|
||||
|
||||
56
app/src/main/java/sh/sar/basedbank/util/ProfileImageStore.kt
Normal file
56
app/src/main/java/sh/sar/basedbank/util/ProfileImageStore.kt
Normal file
@@ -0,0 +1,56 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Stores and loads profile images locally for BML and Fahipay accounts.
|
||||
*
|
||||
* Key conventions:
|
||||
* BML profile: "bml_${profileId}"
|
||||
* Fahipay login: "fahipay_${loginId}"
|
||||
*/
|
||||
object ProfileImageStore {
|
||||
|
||||
private fun dir(context: Context): File =
|
||||
File(context.filesDir, "profile_images").also { it.mkdirs() }
|
||||
|
||||
private fun file(context: Context, key: String): File =
|
||||
File(dir(context), "${key.replace(Regex("[^A-Za-z0-9_\\-]"), "_")}.jpg")
|
||||
|
||||
fun save(context: Context, key: String, bitmap: Bitmap) {
|
||||
try {
|
||||
file(context, key).outputStream().use {
|
||||
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun load(context: Context, key: String): Bitmap? = try {
|
||||
val f = file(context, key)
|
||||
if (f.exists()) BitmapFactory.decodeFile(f.absolutePath) else null
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
fun delete(context: Context, key: String) {
|
||||
try { file(context, key).delete() } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
fun exists(context: Context, key: String): Boolean =
|
||||
try { file(context, key).exists() } catch (_: Exception) { false }
|
||||
|
||||
fun clearAll(context: Context) {
|
||||
try { dir(context).listFiles()?.forEach { it.delete() } } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
/** Key for a BML profile given its profileId. */
|
||||
fun bmlKey(profileId: String) = "bml_$profileId"
|
||||
|
||||
/** Key for a Fahipay login given its loginId. */
|
||||
fun fahipayKey(loginId: String) = "fahipay_$loginId"
|
||||
|
||||
/** Derives the loginId from a loginTag (e.g. "fahipay_abc" → "abc"). */
|
||||
fun loginIdFromTag(loginTag: String): String =
|
||||
loginTag.substringAfter('_', loginTag)
|
||||
}
|
||||
10
app/src/main/res/drawable/ic_camera.xml
Normal file
10
app/src/main/res/drawable/ic_camera.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,15.2c1.767,0 3.2,-1.433 3.2,-3.2s-1.433,-3.2 -3.2,-3.2 -3.2,1.433 -3.2,3.2 1.433,3.2 3.2,3.2zM9,2L7.17,4H4c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2h-3.17L15,2H9zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5z"/>
|
||||
</vector>
|
||||
@@ -189,6 +189,13 @@
|
||||
<string name="login_detail_customer_id">Customer ID</string>
|
||||
<string name="login_detail_id_card">ID Card</string>
|
||||
<string name="login_detail_profiles">Profiles</string>
|
||||
<string name="profile_image_title">Profile photo</string>
|
||||
<string name="profile_image_select">Select image</string>
|
||||
<string name="profile_image_camera">Take from camera</string>
|
||||
<string name="profile_image_remove">Remove</string>
|
||||
<string name="profile_image_uploading">Uploading image…</string>
|
||||
<string name="profile_image_upload_failed">Failed to upload image</string>
|
||||
<string name="profile_image_deleting">Removing image…</string>
|
||||
<string name="close">Close</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="cancel">Cancel</string>
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
<paths>
|
||||
<cache-path name="receipt_cache" path="receipts/" />
|
||||
<cache-path name="qr_cache" path="qr/" />
|
||||
<cache-path name="profile_photo_tmp" path="." />
|
||||
</paths>
|
||||
|
||||
Reference in New Issue
Block a user