fix scroll bug

This commit is contained in:
2026-05-15 10:12:09 +05:00
parent 6779b6f3b7
commit c9ec43de04
8 changed files with 232 additions and 52 deletions
@@ -3,6 +3,7 @@ package sh.sar.basedbank
import android.app.Application import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.sync.Mutex
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
@@ -19,6 +20,9 @@ class BasedBankApp : Application() {
var bmlSession: BmlSession? = null var bmlSession: BmlSession? = null
var bmlAccounts: List<MibAccount> = emptyList() var bmlAccounts: List<MibAccount> = emptyList()
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex()
val mibLoginFlow by lazy { val mibLoginFlow by lazy {
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)) MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE))
} }
@@ -180,8 +180,9 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
.add("data", encrypted) .add("data", encrypted)
.build() .build()
val response = post(formBody) val response = post(formBody)
val result = MibCrypto.decrypt(response, session.sessionKey) // Server returns plain JSON (not encrypted) for error responses (e.g. expired session)
return result if (response.trimStart().startsWith("{")) return JSONObject(response)
return MibCrypto.decrypt(response, session.sessionKey)
} }
private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply { private fun baseData(session: MibSession, routePath: String): JSONObject = JSONObject().apply {
@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -27,6 +29,17 @@ class AccountHistoryAdapter(
} }
private val displayItems = mutableListOf<Item>() private val displayItems = mutableListOf<Item>()
private var lastInsertedDateKey = ""
private val imageCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
displayItems.forEachIndexed { i, item ->
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
notifyItemChanged(i + 1) // +1 for account header at position 0
}
}
private var _showLoadingFooter = false private var _showLoadingFooter = false
var showLoadingFooter: Boolean var showLoadingFooter: Boolean
@@ -43,13 +56,14 @@ class AccountHistoryAdapter(
} }
} }
/** /**
* Display the given (already sorted + filtered) list with date group headers. * Display the given (already sorted + filtered) list with date group headers.
* Silently resets the loading footer so notifyDataSetChanged covers everything. * Silently resets the loading footer so notifyDataSetChanged covers everything.
*/ */
fun setTransactions(transactions: List<Transaction>) { fun setTransactions(transactions: List<Transaction>) {
_showLoadingFooter = false _showLoadingFooter = false
displayItems.clear() displayItems.clear()
lastInsertedDateKey = ""
var lastDateKey = "" var lastDateKey = ""
for (trx in transactions) { for (trx in transactions) {
val dateKey = trx.date.take(10) val dateKey = trx.date.take(10)
@@ -59,9 +73,34 @@ class AccountHistoryAdapter(
} }
displayItems.add(Item.Trx(trx)) displayItems.add(Item.Trx(trx))
} }
lastInsertedDateKey = lastDateKey
notifyDataSetChanged() notifyDataSetChanged()
} }
/**
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
* notifications, so the RecyclerView doesn't reset scroll position.
*/
fun appendTransactions(newTransactions: List<Transaction>) {
if (newTransactions.isEmpty()) return
if (_showLoadingFooter) {
val pos = itemCount - 1
_showLoadingFooter = false
notifyItemRemoved(pos)
}
val oldCount = displayItems.size
for (trx in newTransactions) {
val dateKey = trx.date.take(10)
if (dateKey != lastInsertedDateKey) {
displayItems.add(Item.DateHeader(formatDateHeader(trx.date)))
lastInsertedDateKey = dateKey
}
displayItems.add(Item.Trx(trx))
}
val added = displayItems.size - oldCount
if (added > 0) notifyItemRangeInserted(1 + oldCount, added) // +1 for account header
}
// Position 0 = account header card // Position 0 = account header card
// Positions 1..displayItems.size = date headers + transactions // Positions 1..displayItems.size = date headers + transactions
// Last position = loading footer when showLoadingFooter = true // Last position = loading footer when showLoadingFooter = true
@@ -136,15 +175,25 @@ class AccountHistoryAdapter(
fun bind(trx: Transaction) { fun bind(trx: Transaction) {
val isCredit = trx.amount >= 0 val isCredit = trx.amount >= 0
val color = sourceColor(trx.source) val color = sourceColor(trx.source)
val initial = (trx.counterpartyName ?: trx.description) val name = trx.counterpartyName ?: trx.description
.firstOrNull()?.uppercaseChar()?.toString() ?: "?" val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val circle = GradientDrawable().apply { val bitmap = trx.counterpartyName?.let { imageCache[it] }
shape = GradientDrawable.OVAL if (bitmap != null) {
setColor(Color.parseColor(color)) val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
rd.isCircular = true
b.fvAvatar.background = rd
b.tvInitial.visibility = View.INVISIBLE
} else {
val circle = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor(color))
}
b.fvAvatar.background = circle
b.tvInitial.visibility = View.VISIBLE
b.tvInitial.text = initial
if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
} }
b.fvAvatar.background = circle
b.tvInitial.text = initial
b.tvDescription.text = trx.description b.tvDescription.text = trx.description
val counterparty = trx.counterpartyName val counterparty = trx.counterpartyName
@@ -1,8 +1,10 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
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
@@ -13,14 +15,17 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.ContactImageCache
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
@@ -37,6 +42,7 @@ class AccountHistoryFragment : Fragment() {
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<Transaction>()
private var searchQuery = "" private var searchQuery = ""
private var firstPageDone = false private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>()
// Pagination state // Pagination state
private var mibNextStart = 1 private var mibNextStart = 1
@@ -67,6 +73,7 @@ class AccountHistoryFragment : Fragment() {
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
adapter = AccountHistoryAdapter(account) adapter = AccountHistoryAdapter(account)
adapter.onImageNeeded = { name -> loadContactImage(name) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
@@ -74,7 +81,7 @@ class AccountHistoryFragment : Fragment() {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
val lm = rv.layoutManager as LinearLayoutManager val lm = rv.layoutManager as LinearLayoutManager
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 3) { if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 3) {
loadNextPage() rv.post { loadNextPage() }
} }
} }
}) })
@@ -138,19 +145,20 @@ class AccountHistoryFragment : Fragment() {
when { when {
isMib() -> { isMib() -> {
val session = app.mibSession ?: return@withContext emptyList() val session = app.mibSession ?: return@withContext emptyList()
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId } app.mibMutex.withLock {
if (profile != null) app.mibLoginFlow.switchProfile(session, profile) val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
val client = MibHistoryClient() if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val (list, total) = client.fetchHistory( val (list, total) = MibHistoryClient().fetchHistory(
session = session, session = session,
accountNo = account.accountNumber, accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,
start = mibNextStart, start = mibNextStart,
pageSize = pageSize pageSize = pageSize
) )
if (total > 0) mibTotalCount = total if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize) mibNextStart += list.size.coerceAtLeast(pageSize)
list list
}
} }
isBmlCard() -> { isBmlCard() -> {
val session = app.bmlSession ?: return@withContext emptyList() val session = app.bmlSession ?: return@withContext emptyList()
@@ -196,7 +204,14 @@ class AccountHistoryFragment : Fragment() {
allTransactions.addAll(newOnes) allTransactions.addAll(newOnes)
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions) TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
filterAndDisplay() if (searchQuery.isBlank()) {
// Append incrementally to preserve scroll position
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
adapter.appendTransactions(sorted)
binding.emptyView.visibility = View.GONE
} else {
filterAndDisplay()
}
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
} }
@@ -208,6 +223,30 @@ class AccountHistoryFragment : Fragment() {
} }
} }
private fun loadContactImage(name: String) {
if (!pendingImageNames.add(name)) return
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
val hash = contact.customerImgHash ?: return
val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) {
binding.recyclerView.post { adapter.updateImage(name, cached) }
return
}
val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap)
withContext(Dispatchers.Main) { adapter.updateImage(name, bitmap) }
} catch (_: Exception) {
pendingImageNames.remove(name)
}
}
}
override fun onDestroyView() { override fun onDestroyView() {
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
super.onDestroyView() super.onDestroyView()
@@ -26,6 +26,7 @@ import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
class ContactsFragment : Fragment() { class ContactsFragment : Fragment() {
@@ -212,6 +213,12 @@ class ContactsFragment : Fragment() {
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return if (!pendingHashes.add(hash)) return
// Check disk cache first — if hash matches we already have the image
val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) {
view?.post { pagerAdapter.updateImage(hash, cached) }
return
}
val sess = session ?: return val sess = session ?: return
val client = MibContactsClient() val client = MibContactsClient()
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
@@ -219,6 +226,7 @@ class ContactsFragment : Fragment() {
val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = client.fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap)
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) } withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
} catch (_: Exception) { } catch (_: Exception) {
pendingHashes.remove(hash) pendingHashes.remove(hash)
@@ -1,10 +1,12 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
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 androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
@@ -21,6 +23,16 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
private val displayItems = mutableListOf<Item>() private val displayItems = mutableListOf<Item>()
private val imageCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap
displayItems.forEachIndexed { i, item ->
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
notifyItemChanged(i)
}
}
private var _showLoadingFooter = false private var _showLoadingFooter = false
var showLoadingFooter: Boolean var showLoadingFooter: Boolean
@@ -39,7 +51,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/** Replace the full sorted transaction list and rebuild date groups. */ /** Replace the full sorted transaction list and rebuild date groups. */
fun setTransactions(transactions: List<Transaction>) { fun setTransactions(transactions: List<Transaction>) {
_showLoadingFooter = false // reset silently; notifyDataSetChanged covers it _showLoadingFooter = false
displayItems.clear() displayItems.clear()
var lastDateKey = "" var lastDateKey = ""
for (trx in transactions) { for (trx in transactions) {
@@ -53,10 +65,10 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun getItemCount() = displayItems.size + if (showLoadingFooter) 1 else 0 override fun getItemCount() = displayItems.size + if (_showLoadingFooter) 1 else 0
override fun getItemViewType(position: Int) = when { override fun getItemViewType(position: Int) = when {
showLoadingFooter && position == displayItems.size -> TYPE_LOADING _showLoadingFooter && position == displayItems.size -> TYPE_LOADING
displayItems[position] is Item.DateHeader -> TYPE_DATE_HEADER displayItems[position] is Item.DateHeader -> TYPE_DATE_HEADER
else -> TYPE_TRANSACTION else -> TYPE_TRANSACTION
} }
@@ -88,15 +100,25 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
fun bind(trx: Transaction) { fun bind(trx: Transaction) {
val isCredit = trx.amount >= 0 val isCredit = trx.amount >= 0
val color = AccountHistoryAdapter.sourceColor(trx.source) val color = AccountHistoryAdapter.sourceColor(trx.source)
val initial = (trx.counterpartyName ?: trx.description) val name = trx.counterpartyName ?: trx.description
.firstOrNull()?.uppercaseChar()?.toString() ?: "?" val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val circle = GradientDrawable().apply { val bitmap = trx.counterpartyName?.let { imageCache[it] }
shape = GradientDrawable.OVAL if (bitmap != null) {
setColor(Color.parseColor(color)) val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap)
rd.isCircular = true
b.fvAvatar.background = rd
b.tvInitial.visibility = View.INVISIBLE
} else {
val circle = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor(color))
}
b.fvAvatar.background = circle
b.tvInitial.visibility = View.VISIBLE
b.tvInitial.text = initial
if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName)
} }
b.fvAvatar.background = circle
b.tvInitial.text = initial
b.tvDescription.text = trx.description b.tvDescription.text = trx.description
// Show account name in secondary line for Transfer History // Show account name in secondary line for Transfer History
@@ -1,8 +1,10 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
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
@@ -15,15 +17,18 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
import sh.sar.basedbank.util.ContactImageCache
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
@@ -60,6 +65,7 @@ class TransferHistoryFragment : Fragment() {
private var isLoading = false private var isLoading = false
private var firstBatchDone = false private var firstBatchDone = false
private val pageSize = 20 private val pageSize = 20
private val pendingImageNames = mutableSetOf<String>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentTransferHistoryBinding.inflate(inflater, container, false) _binding = FragmentTransferHistoryBinding.inflate(inflater, container, false)
@@ -68,6 +74,7 @@ class TransferHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = TransactionAdapter() adapter = TransactionAdapter()
adapter.onImageNeeded = { name -> loadContactImage(name) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
@@ -76,7 +83,7 @@ class TransferHistoryFragment : Fragment() {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
val lm = rv.layoutManager as LinearLayoutManager val lm = rv.layoutManager as LinearLayoutManager
if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 5) { if (lm.findLastVisibleItemPosition() >= adapter.itemCount - 5) {
loadNextPages() rv.post { loadNextPages() }
} }
} }
}) })
@@ -171,25 +178,27 @@ class TransferHistoryFragment : Fragment() {
} }
}.awaitAll().flatten()) }.awaitAll().flatten())
// MIB accounts: serialized per profile to avoid session race // MIB accounts: serialized per profile, protected by mutex to prevent session race
val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") } val mibStates = activeStates.filter { !it.account.profileType.startsWith("BML") }
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) { for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break val session = mibSession ?: break
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId } app.mibMutex.withLock {
if (profile != null) app.mibLoginFlow.switchProfile(session, profile) val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
for (state in states) { if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
try { for (state in states) {
val (list, total) = MibHistoryClient().fetchHistory( try {
session = session, val (list, total) = MibHistoryClient().fetchHistory(
accountNo = state.account.accountNumber, session = session,
accountDisplayName = state.account.accountBriefName, accountNo = state.account.accountNumber,
start = state.mibNextStart, accountDisplayName = state.account.accountBriefName,
pageSize = pageSize start = state.mibNextStart,
) pageSize = pageSize
if (total > 0) state.mibTotalCount = total )
state.mibNextStart += list.size.coerceAtLeast(pageSize) if (total > 0) state.mibTotalCount = total
results.addAll(list) state.mibNextStart += list.size.coerceAtLeast(pageSize)
} catch (_: Exception) {} results.addAll(list)
} catch (_: Exception) {}
}
} }
} }
@@ -232,6 +241,30 @@ class TransferHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
} }
private fun loadContactImage(name: String) {
if (!pendingImageNames.add(name)) return
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
val hash = contact.customerImgHash ?: return
val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) {
binding.recyclerView.post { adapter.updateImage(name, cached) }
return
}
val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
ContactImageCache.save(requireContext(), hash, bitmap)
withContext(Dispatchers.Main) { adapter.updateImage(name, bitmap) }
} catch (_: Exception) {
pendingImageNames.remove(name)
}
}
}
override fun onDestroyView() { override fun onDestroyView() {
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
super.onDestroyView() super.onDestroyView()
@@ -0,0 +1,24 @@
package sh.sar.basedbank.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
object ContactImageCache {
private fun file(context: Context, hash: String) =
File(context.cacheDir, "cimg_${hash.replace(Regex("[^A-Za-z0-9]"), "_")}.png")
fun save(context: Context, hash: String, bitmap: Bitmap) {
try {
file(context, hash).outputStream().use {
bitmap.compress(Bitmap.CompressFormat.PNG, 90, it)
}
} catch (_: Exception) {}
}
fun load(context: Context, hash: String): Bitmap? = try {
BitmapFactory.decodeFile(file(context, hash).absolutePath)
} catch (_: Exception) { null }
}