diff --git a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt index dc9eef0..5a14071 100644 --- a/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/mib/MibLoginFlow.kt @@ -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) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt index 8fc7ad3..3919621 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt @@ -21,7 +21,9 @@ class AccountsAdapter( accounts: List, 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() { 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) + } } } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt index bd441c1..1325c6c 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsFragment.kt @@ -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 -> diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt index f71ff37..7424db1 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/ContactPickerSheetFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt index 5d84a88..2fb60f4 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt index 8e7ea6f..ce60267 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt @@ -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 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 } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index d37ece6..1f7fb0b 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -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 diff --git a/app/src/main/java/sh/sar/basedbank/util/ProfileImageStore.kt b/app/src/main/java/sh/sar/basedbank/util/ProfileImageStore.kt new file mode 100644 index 0000000..307cc2f --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/ProfileImageStore.kt @@ -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) +} diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 0000000..93a1c26 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f314a2d..8713e9c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -189,6 +189,13 @@ Customer ID ID Card Profiles + Profile photo + Select image + Take from camera + Remove + Uploading image… + Failed to upload image + Removing image… Close Save Cancel diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 34e0521..78bd93e 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -2,4 +2,5 @@ +