diff --git a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt index ad8482c..d0db647 100644 --- a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt +++ b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt @@ -84,7 +84,7 @@ class FahipayLoginFlow { "grant_type" to "auth_id", "lang" to "en", "version" to "2.0.0", - "platform" to "app", + "platform" to "BasedBank", *deviceParts(deviceUuid) ) @@ -120,7 +120,7 @@ class FahipayLoginFlow { "grant_type" to "auth_id", "lang" to "en", "version" to "2.0.0", - "platform" to "app", + "platform" to "BasedBank", *deviceParts(deviceUuid) ) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt index 2d5b9ad..fa27937 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransactionAdapter.kt @@ -24,7 +24,9 @@ class TransactionAdapter : RecyclerView.Adapter() { private val displayItems = mutableListOf() private val imageCache = mutableMapOf() + private val iconUrlCache = mutableMapOf() var onImageNeeded: ((counterpartyName: String) -> Unit)? = null + var onIconUrlNeeded: ((url: String) -> Unit)? = null fun updateImage(counterpartyName: String, bitmap: Bitmap) { imageCache[counterpartyName] = bitmap @@ -34,6 +36,14 @@ class TransactionAdapter : RecyclerView.Adapter() { } } + fun updateIconUrl(url: String, bitmap: Bitmap) { + iconUrlCache[url] = bitmap + displayItems.forEachIndexed { i, item -> + if (item is Item.Trx && item.transaction.iconUrl == url) + notifyItemChanged(i) + } + } + private var _showLoadingFooter = false var showLoadingFooter: Boolean get() = _showLoadingFooter @@ -103,7 +113,9 @@ class TransactionAdapter : RecyclerView.Adapter() { val name = trx.counterpartyName ?: trx.description val initial = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?" - val bitmap = trx.counterpartyName?.let { imageCache[it] } + val iconBitmap = trx.iconUrl?.let { iconUrlCache[it] } + val contactBitmap = if (iconBitmap == null) trx.counterpartyName?.let { imageCache[it] } else null + val bitmap = iconBitmap ?: contactBitmap if (bitmap != null) { val rd = RoundedBitmapDrawableFactory.create(b.root.resources, bitmap) rd.isCircular = true @@ -117,7 +129,8 @@ class TransactionAdapter : RecyclerView.Adapter() { b.fvAvatar.background = circle b.tvInitial.visibility = View.VISIBLE b.tvInitial.text = initial - if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName) + if (trx.iconUrl != null) onIconUrlNeeded?.invoke(trx.iconUrl) + else if (trx.counterpartyName != null) onImageNeeded?.invoke(trx.counterpartyName) } b.tvDescription.text = trx.description diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt index 0212fb0..84a407d 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt @@ -1,6 +1,8 @@ package sh.sar.basedbank.ui.home import android.graphics.BitmapFactory +import okhttp3.OkHttpClient +import okhttp3.Request import android.os.Bundle import android.text.Editable import android.text.TextWatcher @@ -22,6 +24,7 @@ import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibHistoryClient @@ -29,6 +32,7 @@ import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding import sh.sar.basedbank.util.ContactImageCache +import sh.sar.basedbank.util.MerchantIconCache import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -52,9 +56,12 @@ class TransferHistoryFragment : Fragment() { var mibTotalCount: Int = -1, var bmlNextPage: Int = 1, var bmlTotalPages: Int = -1, - var cardMonthOffset: Int = 0 + var cardMonthOffset: Int = 0, + var fahipayNextStart: Int = 0, + var fahipayTotal: Int = -1 ) { fun hasMore(): Boolean = when { + account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.profileType == "BML_PREPAID" -> cardMonthOffset < 2 account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount @@ -66,6 +73,7 @@ class TransferHistoryFragment : Fragment() { private var firstBatchDone = false private val pageSize = 20 private val pendingImageNames = mutableSetOf() + private val pendingIconUrls = mutableSetOf() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentTransferHistoryBinding.inflate(inflater, container, false) @@ -75,6 +83,7 @@ class TransferHistoryFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { adapter = TransactionAdapter() adapter.onImageNeeded = { name -> loadContactImage(name) } + adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter @@ -178,8 +187,29 @@ class TransferHistoryFragment : Fragment() { } }.awaitAll().flatten()) + // Fahipay accounts + val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" } + for (state in fahipayStates) { + val session = app.fahipaySession ?: continue + try { + val flow = FahipayLoginFlow() + flow.setSessionCookie(session.sessionCookie) + val (list, total) = flow.fetchHistory( + session = session, + accountDisplayName = state.account.accountBriefName, + accountNumber = state.account.accountNumber, + start = state.fahipayNextStart + ) + if (total > 0) state.fahipayTotal = total + state.fahipayNextStart += list.size + results.addAll(list) + } catch (_: Exception) {} + } + // 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") && it.account.profileType != "FAHIPAY" + } for ((profileId, states) in mibStates.groupBy { it.account.profileId }) { val session = mibSession ?: break app.mibMutex.withLock { @@ -265,6 +295,28 @@ class TransferHistoryFragment : Fragment() { } } + private fun loadMerchantIcon(url: String) { + if (!pendingIconUrls.add(url)) return + val cached = MerchantIconCache.load(requireContext(), url) + if (cached != null) { + binding.recyclerView.post { adapter.updateIconUrl(url, cached) } + return + } + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + try { + val client = OkHttpClient() + val response = client.newCall(Request.Builder().url(url).build()).execute() + val bytes = response.body?.bytes() ?: return@launch + response.close() + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch + MerchantIconCache.save(requireContext(), url, bitmap) + withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) } + } catch (_: Exception) { + pendingIconUrls.remove(url) + } + } + } + override fun onDestroyView() { (activity as? HomeActivity)?.setRefreshing(false) super.onDestroyView() diff --git a/docs/fahipayapi/01-login.md b/docs/fahipayapi/01-login.md index 63c0010..2634d6e 100644 --- a/docs/fahipayapi/01-login.md +++ b/docs/fahipayapi/01-login.md @@ -25,7 +25,7 @@ POST https://fahipay.mv/api/app/login/ | `grant_type` | `auth_id` | Always `auth_id` | | `lang` | `en` | Always `en` | | `version` | `2.0.0` | App version string | -| `platform` | `BasedBank` | Client identifier (original app sends `app`) | +| `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) | | `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) | | `device[platform]` | `Android` | | | `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install | @@ -87,7 +87,7 @@ The user has TOTP two-factor authentication enabled. Proceed to the [OTP step](0 | `two_factor_method` | `string` | `"totp"` — standard TOTP (RFC 6238) | | `type` | `string` | `"success"` on success, `"error"` on failure | -The server sets the `__Secure-sess` session cookie on this response. It must be included in all subsequent requests. +The `__Secure-sess` session cookie is obtained from the session initialisation step (see [Session Cookie](#session-cookie) below), not from this response. --- @@ -128,7 +128,14 @@ The user does not have 2FA enabled. The `authID` is returned directly — no OTP ## Session Cookie -The `__Secure-sess` cookie is set by the server on the first response and must be sent on every subsequent request. It is a standard HTTP cookie with the `Secure` flag. +Before calling `/api/app/login/`, the client must make an initialisation request to obtain the `__Secure-sess` cookie: + +``` +GET https://fahipay.mv/api/app/lang/data/ +User-Agent: +``` + +The server sets the `__Secure-sess` cookie on this response. It must be sent with every subsequent request (login, OTP, and all authenticated calls). It is a standard HTTP cookie with the `Secure` flag: ``` Set-Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx; Path=/; Secure; HttpOnly; SameSite=Strict diff --git a/docs/fahipayapi/02-otp.md b/docs/fahipayapi/02-otp.md index 3ac81e9..035758e 100644 --- a/docs/fahipayapi/02-otp.md +++ b/docs/fahipayapi/02-otp.md @@ -34,7 +34,7 @@ POST https://fahipay.mv/api/app/otp/ | `grant_type` | `auth_id` | Always `auth_id` | | `lang` | `en` | Always `en` | | `version` | `2.0.0` | App version string | -| `platform` | `BasedBank` | Client identifier | +| `platform` | `BasedBank` | Client identifier (`app` in the original Fahipay app) | | `device[available]` | `true` | Same device fields as login — must match | | `device[platform]` | `Android` | | | `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request | diff --git a/docs/fahipayapi/README.md b/docs/fahipayapi/README.md index b2e8c9b..57e239d 100644 --- a/docs/fahipayapi/README.md +++ b/docs/fahipayapi/README.md @@ -77,6 +77,11 @@ The `device[uuid]` must be consistent across all requests from the same install. ``` Client Server + | | + | GET /api/app/lang/data/ | ← session init (obtains __Secure-sess cookie) + |---------------------------------->| + | Set-Cookie: __Secure-sess=... | + |<----------------------------------| | | | POST /api/app/login/ | | { email=IDCARD, password, ... } |