Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
94b280a177
|
|||
|
88c9f153e5
|
|||
|
eb7da01b2e
|
|||
|
27270f1b7a
|
|||
|
fd7fcb41a6
|
|||
|
c9ae614fc7
|
|||
|
b784085605
|
|||
|
01e5c17284
|
|||
|
6d3c7036b5
|
|||
|
804712d22d
|
|||
|
f208ee6ad1
|
|||
|
51dbed94d4
|
|||
|
0b5a452046
|
|||
|
00297da71e
|
|||
|
1602d061c1
|
|||
|
ddd64e8624
|
|||
|
77f367844d
|
|||
|
e2729b1d1a
|
|||
|
105518e147
|
|||
|
38570615dd
|
|||
|
e82218e897
|
|||
|
50150b826f
|
|||
|
2d705457f8
|
|||
|
f03e23062b
|
|||
|
58f1b9fd6f
|
|||
|
240d04ad74
|
|||
|
fe507073b1
|
|||
|
6d48c27391
|
|||
|
e894f81887
|
|||
|
acc1278b34
|
|||
|
bc678d26ad
|
|||
|
bb2a80a5e3
|
|||
|
b107358266
|
|||
|
02a53c8219
|
|||
|
15a02cac1c
|
|||
|
35a1748055
|
|||
|
28682bba41
|
|||
|
25484addfb
|
|||
|
728c7d2aa3
|
|||
|
b24949c117
|
|||
|
28e5878668
|
|||
|
b1e73533f6
|
|||
|
3a5b9459a9
|
|||
|
9c9729e268
|
|||
|
399cfbf108
|
|||
|
19f4d01015
|
|||
|
8c40322ff0
|
|||
|
782e2e7674
|
|||
|
89a9731797
|
|||
|
50badc7d54
|
|||
|
d4f86bb738
|
|||
|
b35f44f35b
|
|||
|
1a58ce8b54
|
|||
|
2dd84ec50a
|
|||
|
33651ca107
|
|||
|
ae307e3118
|
|||
|
3ab75bff92
|
|||
|
1753d648bd
|
|||
|
423b0bf1e1
|
|||
|
d713047970
|
|||
|
d59a6fad82
|
|||
|
8e47101401
|
|||
|
9431a90cd0
|
|||
|
3a10f36c39
|
21
.idea/deploymentTargetSelector.xml
generated
21
.idea/deploymentTargetSelector.xml
generated
@@ -3,15 +3,28 @@
|
|||||||
<component name="deploymentTargetSelector">
|
<component name="deploymentTargetSelector">
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DIALOG" />
|
||||||
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
|
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection>
|
||||||
|
<targets>
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=67d022c2" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</targets>
|
||||||
|
</DialogSelection>
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 6
|
||||||
versionName = "1.0"
|
versionName = "1.0.7"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -64,12 +64,18 @@ dependencies {
|
|||||||
// RecyclerView for accounts list
|
// RecyclerView for accounts list
|
||||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||||
|
|
||||||
|
// CircularProgressDrawable for spinning search icons
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
// OkHttp for API calls
|
// OkHttp for API calls
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// ZXing core for QR code generation
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
|
||||||
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
||||||
implementation("androidx.camera:camera-core:1.4.2")
|
implementation("androidx.camera:camera-core:1.4.2")
|
||||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||||
|
|||||||
@@ -48,7 +48,8 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
android:exported="false" />
|
android:exported="false"
|
||||||
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.QrScannerActivity"
|
android:name=".ui.home.QrScannerActivity"
|
||||||
|
|||||||
BIN
app/src/main/assets/cards/mib/visa_black_platinum.jpg
Normal file
BIN
app/src/main/assets/cards/mib/visa_black_platinum.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 242 KiB |
BIN
app/src/main/assets/cards/mib/visa_blue_everyday.jpg
Normal file
BIN
app/src/main/assets/cards/mib/visa_blue_everyday.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 378 KiB |
BIN
app/src/main/assets/cards/mib/visa_business.jpg
Normal file
BIN
app/src/main/assets/cards/mib/visa_business.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 633 KiB |
@@ -4,9 +4,11 @@ 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 kotlinx.coroutines.sync.Mutex
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.MibSession
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
@@ -15,35 +17,95 @@ import sh.sar.basedbank.util.CredentialStore
|
|||||||
class BasedBankApp : Application() {
|
class BasedBankApp : Application() {
|
||||||
|
|
||||||
// Held in memory after successful login; cleared on logout
|
// Held in memory after successful login; cleared on logout
|
||||||
var accounts: List<MibAccount> = emptyList()
|
var accounts: List<BankAccount> = emptyList()
|
||||||
var fullName: String = ""
|
var fullName: String = ""
|
||||||
var mibSession: MibSession? = null
|
|
||||||
var mibProfiles: List<MibProfile> = emptyList()
|
|
||||||
/** Active BML sessions keyed by loginId (= BML username). */
|
|
||||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
|
||||||
var bmlAccounts: List<MibAccount> = emptyList()
|
|
||||||
var fahipaySession: FahipaySession? = null
|
|
||||||
var fahipayAccounts: List<MibAccount> = emptyList()
|
|
||||||
|
|
||||||
/** Returns the BML session for the given account (matched via loginTag). */
|
/** Active MIB sessions keyed by loginId (= MIB username). */
|
||||||
fun bmlSessionFor(account: MibAccount): BmlSession? =
|
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
|
||||||
bmlSessions[account.loginTag.removePrefix("bml_")]
|
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
|
||||||
|
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
|
||||||
|
var mibAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
|
||||||
|
* Use [bmlSessionFor] to look up the session for an account.
|
||||||
|
*/
|
||||||
|
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||||
|
/** BML profiles per loginId (= BML username). */
|
||||||
|
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||||
|
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||||
|
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
|
||||||
|
var bmlAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
||||||
|
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||||
|
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||||
|
fun mibSessionFor(account: BankAccount): MibSession? =
|
||||||
|
mibSessions[account.loginTag.removePrefix("mib_")]
|
||||||
|
|
||||||
|
/** Returns any available MIB session. */
|
||||||
|
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
|
||||||
|
|
||||||
|
/** Returns all MIB profiles across all logins. */
|
||||||
|
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
|
||||||
|
|
||||||
|
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
|
||||||
|
fun mibFlowFor(loginId: String): MibLoginFlow =
|
||||||
|
mibLoginFlows.getOrPut(loginId) {
|
||||||
|
MibLoginFlow(CredentialStore(this)).also { flow ->
|
||||||
|
flow.onSessionRefreshed = { session, profiles ->
|
||||||
|
mibSessions[loginId] = session
|
||||||
|
mibProfilesMap[loginId] = profiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns any available MibLoginFlow. */
|
||||||
|
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
|
||||||
|
|
||||||
|
// ─── BML helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the BML session for the given account.
|
||||||
|
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
|
||||||
|
*/
|
||||||
|
fun bmlSessionFor(account: BankAccount): BmlSession? {
|
||||||
|
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
|
||||||
|
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns any available BML session (for non-account-specific operations). */
|
/** Returns any available BML session (for non-account-specific operations). */
|
||||||
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns any active BML session for the given loginId.
|
||||||
|
* Tries all profiles for that login; falls back to legacy loginId key.
|
||||||
|
*/
|
||||||
|
fun anyBmlSessionFor(loginId: String): BmlSession? {
|
||||||
|
val profiles = bmlProfilesMap[loginId]
|
||||||
|
if (!profiles.isNullOrEmpty()) {
|
||||||
|
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
|
||||||
|
}
|
||||||
|
return bmlSessions[loginId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
|
||||||
|
fun bmlFlowFor(loginId: String): BmlLoginFlow =
|
||||||
|
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
|
||||||
|
|
||||||
|
// ─── Fahipay helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
|
||||||
|
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||||
|
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||||
|
|
||||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||||
val mibMutex = Mutex()
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
val mibLoginFlow by lazy {
|
|
||||||
MibLoginFlow(CredentialStore(this)).also { flow ->
|
|
||||||
flow.onSessionRefreshed = { session, profiles ->
|
|
||||||
mibSession = session
|
|
||||||
mibProfiles = profiles
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -18,8 +21,6 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import javax.crypto.SecretKeyFactory
|
import javax.crypto.SecretKeyFactory
|
||||||
import javax.crypto.spec.PBEKeySpec
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
@@ -31,7 +32,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
private lateinit var salt: String
|
private lateinit var salt: String
|
||||||
private lateinit var storedHash: String
|
private lateinit var storedHash: String
|
||||||
private var biometricsEnabled = false
|
private var biometricsEnabled = false
|
||||||
private var isLegacyFormat = false
|
private var autoUnlockPin = false
|
||||||
|
private var pinLength = 4
|
||||||
private var isVerifying = false
|
private var isVerifying = false
|
||||||
|
|
||||||
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||||
@@ -39,28 +41,34 @@ class LockActivity : AppCompatActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val MAX_ATTEMPTS = 5
|
private const val MAX_ATTEMPTS = 5
|
||||||
private const val LOCKOUT_MS = 30_000L
|
private const val LOCKOUT_MS = 30_000L
|
||||||
|
const val EXTRA_RESUME = "resume"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
autoUnlockPin = prefs.getBoolean("auto_unlock_pin", false)
|
||||||
|
pinLength = prefs.getInt("pin_length", 4)
|
||||||
|
|
||||||
// Try new encrypted format first; fall back to legacy SHA-256
|
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||||
val stored = CredentialStore(this).loadSecurityHash()
|
salt = stored.first
|
||||||
if (stored != null) {
|
storedHash = stored.second
|
||||||
salt = stored.first
|
|
||||||
storedHash = stored.second
|
|
||||||
isLegacyFormat = false
|
|
||||||
} else {
|
|
||||||
salt = prefs.getString("security_salt", "") ?: ""
|
|
||||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
|
||||||
isLegacyFormat = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method == "pin") {
|
if (method == "pin") {
|
||||||
binding.viewPin.visibility = View.VISIBLE
|
binding.viewPin.visibility = View.VISIBLE
|
||||||
@@ -130,13 +138,18 @@ class LockActivity : AppCompatActivity() {
|
|||||||
when (key) {
|
when (key) {
|
||||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||||
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() }
|
else -> if (pinDigits.size < 8) {
|
||||||
|
pinDigits.add(key.toInt())
|
||||||
|
updateDots()
|
||||||
|
if (autoUnlockPin && pinDigits.size == pinLength) verifyPin()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDots() {
|
private fun updateDots() {
|
||||||
val n = pinDigits.size
|
val n = pinDigits.size
|
||||||
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(4 - n, 0))
|
val total = if (autoUnlockPin) pinLength else maxOf(n, 4)
|
||||||
|
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(total - n, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun verifyPin() {
|
private fun verifyPin() {
|
||||||
@@ -149,7 +162,6 @@ class LockActivity : AppCompatActivity() {
|
|||||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
isVerifying = false
|
isVerifying = false
|
||||||
if (ok) {
|
if (ok) {
|
||||||
migrateIfNeeded(entered)
|
|
||||||
resetFailures()
|
resetFailures()
|
||||||
proceed()
|
proceed()
|
||||||
} else {
|
} else {
|
||||||
@@ -174,7 +186,6 @@ class LockActivity : AppCompatActivity() {
|
|||||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
isVerifying = false
|
isVerifying = false
|
||||||
if (ok) {
|
if (ok) {
|
||||||
migrateIfNeeded(entered)
|
|
||||||
resetFailures()
|
resetFailures()
|
||||||
proceed()
|
proceed()
|
||||||
} else {
|
} else {
|
||||||
@@ -216,30 +227,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun verify(input: String): Boolean {
|
private fun verify(input: String): Boolean {
|
||||||
if (storedHash.isBlank()) return false
|
if (storedHash.isBlank()) return false
|
||||||
return if (isLegacyFormat) {
|
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||||
sha256Legacy(salt + input) == storedHash
|
return pbkdf2(input, saltBytes) == storedHash
|
||||||
} else {
|
|
||||||
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
|
||||||
pbkdf2(input, saltBytes) == storedHash
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On the first successful unlock after legacy SHA-256 format is detected,
|
|
||||||
* transparently migrate to PBKDF2 + CredentialStore.
|
|
||||||
*/
|
|
||||||
private fun migrateIfNeeded(input: String) {
|
|
||||||
if (!isLegacyFormat) return
|
|
||||||
try {
|
|
||||||
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
|
||||||
val newHash = pbkdf2(input, newSalt)
|
|
||||||
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
|
|
||||||
CredentialStore(this).saveSecurityHash(saltB64, newHash)
|
|
||||||
// Remove legacy plaintext fields
|
|
||||||
getSharedPreferences("prefs", MODE_PRIVATE).edit()
|
|
||||||
.remove("security_salt").remove("security_hash").apply()
|
|
||||||
isLegacyFormat = false
|
|
||||||
} catch (_: Exception) { /* migration will retry next unlock */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerBiometric() {
|
private fun triggerBiometric() {
|
||||||
@@ -270,8 +259,12 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun proceed() {
|
private fun proceed() {
|
||||||
startActivity(Intent(this, HomeActivity::class.java))
|
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||||
finish()
|
finish()
|
||||||
|
} else {
|
||||||
|
startActivity(Intent(this, HomeActivity::class.java))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Brute-force tracking ──────────────────────────────────────────────────
|
// ── Brute-force tracking ──────────────────────────────────────────────────
|
||||||
@@ -308,9 +301,4 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
|
|
||||||
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
|
|
||||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
198
app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt
Normal file
198
app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
|
data class BmlUserInfo(
|
||||||
|
val fullName: String,
|
||||||
|
val email: String,
|
||||||
|
val mobile: String,
|
||||||
|
val customerId: String,
|
||||||
|
val idCard: String,
|
||||||
|
val birthdate: String
|
||||||
|
)
|
||||||
|
|
||||||
|
class BmlAccountClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
fun fetchAccounts(
|
||||||
|
session: BmlSession,
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String = "Personal",
|
||||||
|
profileId: String = ""
|
||||||
|
): List<BankAccount> {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/dashboard")).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string()
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight call to verify the session is alive. Throws [AuthExpiredException] on 401/419. */
|
||||||
|
fun checkProfile(session: BmlSession) {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/profile")).execute()
|
||||||
|
val code = resp.code
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
|
||||||
|
val json = resp.body?.string() ?: return null
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return null
|
||||||
|
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
||||||
|
BmlUserInfo(
|
||||||
|
fullName = user.optString("fullname").trim(),
|
||||||
|
email = user.optString("email").trim(),
|
||||||
|
mobile = user.optString("mobile_phone").trim(),
|
||||||
|
customerId = user.optString("customer_number").trim(),
|
||||||
|
idCard = user.optString("idcard").trim(),
|
||||||
|
birthdate = user.optString("birthdate").trim()
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string() ?: return null
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return null
|
||||||
|
val p = root.optJSONObject("payload") ?: return null
|
||||||
|
BmlLoanDetail(
|
||||||
|
loanAmount = p.optDouble("loanAmount", 0.0),
|
||||||
|
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
|
||||||
|
repayAmount = p.optDouble("repayAmount", 0.0),
|
||||||
|
intRate = p.optDouble("intRate", 0.0),
|
||||||
|
loanStatus = p.optString("loanStatus"),
|
||||||
|
startDate = p.optString("startDate"),
|
||||||
|
endDate = p.optString("endDate"),
|
||||||
|
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
|
||||||
|
overdueAmount = p.optDouble("overdueAmount", 0.0)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchTransferChannels(session: BmlSession): List<BmlOtpChannel> {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/transfer")).execute()
|
||||||
|
val json = resp.body?.string() ?: run { resp.close(); return emptyList() }
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val arr = root.optJSONObject("payload")
|
||||||
|
?.optJSONObject("transfer")
|
||||||
|
?.optJSONArray("otpChannel") ?: return emptyList()
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val ch = arr.getJSONObject(i)
|
||||||
|
BmlOtpChannel(
|
||||||
|
channel = ch.optString("channel"),
|
||||||
|
description = ch.optString("description"),
|
||||||
|
masked = ch.optString("masked")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDashboard(
|
||||||
|
json: String,
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String,
|
||||||
|
profileId: String
|
||||||
|
): List<BankAccount> {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||||
|
|
||||||
|
val casaAccounts = mutableListOf<BankAccount>()
|
||||||
|
val prepaidCards = mutableListOf<BankAccount>()
|
||||||
|
val loanAccounts = mutableListOf<BankAccount>()
|
||||||
|
|
||||||
|
for (i in 0 until dashboard.length()) {
|
||||||
|
val item = dashboard.getJSONObject(i)
|
||||||
|
val currency = item.optString("currency", "MVR")
|
||||||
|
val accountType = item.optString("account_type", "CASA")
|
||||||
|
val product = item.optString("product")
|
||||||
|
val accountNumber = item.optString("account")
|
||||||
|
val status = item.optString("account_status", "Active")
|
||||||
|
val internalId = item.optString("id", "")
|
||||||
|
|
||||||
|
if (accountType == "CASA") {
|
||||||
|
val available = item.optDouble("availableBalance", 0.0)
|
||||||
|
casaAccounts.add(BankAccount(
|
||||||
|
bank = "BML",
|
||||||
|
profileName = profileName,
|
||||||
|
profileType = "BML",
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountBriefName = item.optString("alias"),
|
||||||
|
currencyName = currency,
|
||||||
|
accountTypeName = product,
|
||||||
|
availableBalance = "%.2f".format(available),
|
||||||
|
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
||||||
|
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
||||||
|
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||||
|
statusDesc = status,
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
|
internalId = internalId
|
||||||
|
))
|
||||||
|
} else if (accountType == "Loan") {
|
||||||
|
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
|
||||||
|
loanAccounts.add(BankAccount(
|
||||||
|
bank = "BML",
|
||||||
|
profileName = profileName,
|
||||||
|
profileType = "BML_LOAN",
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountBriefName = item.optString("alias"),
|
||||||
|
currencyName = currency,
|
||||||
|
accountTypeName = product,
|
||||||
|
availableBalance = "%.2f".format(outstanding),
|
||||||
|
currentBalance = "%.2f".format(outstanding),
|
||||||
|
blockedAmount = "0.00",
|
||||||
|
mvrBalance = "0.00",
|
||||||
|
statusDesc = status,
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
|
internalId = internalId
|
||||||
|
))
|
||||||
|
} else if (accountType == "Card") {
|
||||||
|
val isVisible = item.optBoolean("account_visible", false)
|
||||||
|
if (!isVisible) continue
|
||||||
|
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||||
|
val cardBalance = item.optJSONObject("cardBalance")
|
||||||
|
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||||
|
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||||
|
prepaidCards.add(BankAccount(
|
||||||
|
bank = "BML",
|
||||||
|
profileName = profileName,
|
||||||
|
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountBriefName = item.optString("alias").ifBlank { product },
|
||||||
|
currencyName = currency,
|
||||||
|
accountTypeName = product,
|
||||||
|
availableBalance = "%.2f".format(available),
|
||||||
|
currentBalance = "%.2f".format(current),
|
||||||
|
blockedAmount = "0.00",
|
||||||
|
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||||
|
statusDesc = status,
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = loginTag,
|
||||||
|
profileId = profileId,
|
||||||
|
internalId = internalId
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return casaAccounts + prepaidCards + loanAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
|
internal const val BML_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||||
|
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||||
|
|
||||||
|
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||||
|
Request.Builder().url(url)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.build()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
|
||||||
|
class BmlContactsClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
fun fetchContacts(session: BmlSession, loginId: String): List<BankContact> {
|
||||||
|
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/contacts")).execute()
|
||||||
|
val json = resp.body?.string() ?: return emptyList()
|
||||||
|
resp.close()
|
||||||
|
return parseContacts(json, loginId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveContact(
|
||||||
|
session: BmlSession,
|
||||||
|
contactType: String,
|
||||||
|
account: String,
|
||||||
|
alias: String,
|
||||||
|
currency: String? = null,
|
||||||
|
name: String? = null,
|
||||||
|
swift: String? = null
|
||||||
|
): Boolean {
|
||||||
|
val bodyObj = JSONObject().apply {
|
||||||
|
put("contact_type", contactType)
|
||||||
|
put("account", account)
|
||||||
|
put("alias", alias)
|
||||||
|
if (currency != null) put("currency", currency)
|
||||||
|
if (name != null) put("name", name)
|
||||||
|
if (swift != null) put("swift", swift)
|
||||||
|
}
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts")
|
||||||
|
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return false
|
||||||
|
resp.close()
|
||||||
|
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
||||||
|
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts/$contactId")
|
||||||
|
.post(body)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val bodyStr = resp.body?.string() ?: return false
|
||||||
|
resp.close()
|
||||||
|
return try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseContacts(json: String, loginId: String): List<BankContact> {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||||
|
val result = mutableListOf<BankContact>()
|
||||||
|
for (i in 0 until payload.length()) {
|
||||||
|
val item = payload.getJSONObject(i)
|
||||||
|
val account = item.optString("account", "")
|
||||||
|
if (account.isBlank()) continue
|
||||||
|
result.add(BankContact(
|
||||||
|
benefNo = "bml_${item.optInt("id")}",
|
||||||
|
benefName = item.optString("name"),
|
||||||
|
benefNickName = item.optString("alias", item.optString("name")),
|
||||||
|
benefAccount = account,
|
||||||
|
benefType = "I",
|
||||||
|
bankColor = "#0066A1",
|
||||||
|
benefBankName = "Bank of Maldives",
|
||||||
|
bankCode = "",
|
||||||
|
benefStatus = item.optString("status", "S"),
|
||||||
|
transferCyDesc = item.optString("currency", "MVR"),
|
||||||
|
customerImgHash = null,
|
||||||
|
benefCategoryId = "BML",
|
||||||
|
profileId = loginId
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class BmlForeignLimitsClient {
|
||||||
|
|
||||||
|
// Foreign limits use a different host than the main BML API
|
||||||
|
private val BASE_URL = "https://app.bankofmaldives.com.mv/api/v2"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/foreign-limits")
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string()
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
return parseForeignLimits(json ?: return emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||||
|
(0 until payload.length()).map { i ->
|
||||||
|
val item = payload.getJSONObject(i)
|
||||||
|
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
||||||
|
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
||||||
|
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
||||||
|
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
||||||
|
BmlForeignLimit(
|
||||||
|
type = item.optString("type", "Debit"),
|
||||||
|
used = item.optDouble("used", 0.0),
|
||||||
|
totalLimit = item.optDouble("totalLimit", 0.0),
|
||||||
|
generalCap = item.optDouble("generalCap", 0.0),
|
||||||
|
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
||||||
|
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
||||||
|
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
||||||
|
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
||||||
|
atmRemaining = atm.optDouble("remaining", 0.0),
|
||||||
|
atmLimit = atm.optDouble("limit", 0.0),
|
||||||
|
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
||||||
|
ecomLimit = ecom.optDouble("limit", 0.0),
|
||||||
|
posRemaining = pos.optDouble("remaining", 0.0),
|
||||||
|
posLimit = pos.optDouble("limit", 0.0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
}
|
||||||
174
app/src/main/java/sh/sar/basedbank/api/bml/BmlHistoryClient.kt
Normal file
174
app/src/main/java/sh/sar/basedbank/api/bml/BmlHistoryClient.kt
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class BmlHistoryClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
fun fetchAccountHistory(
|
||||||
|
session: BmlSession,
|
||||||
|
accountId: String,
|
||||||
|
accountDisplayName: String,
|
||||||
|
accountNumber: String,
|
||||||
|
page: Int
|
||||||
|
): Pair<List<BankTransaction>, Int> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BML_BASE_URL/api/mobile/account/$accountId/history/$page")
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||||
|
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
||||||
|
val totalPages = payload.optInt("totalPages", 0)
|
||||||
|
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
||||||
|
val transactions = (0 until history.length()).map { i ->
|
||||||
|
val item = history.getJSONObject(i)
|
||||||
|
val desc = item.optString("description").trim()
|
||||||
|
val narrative1 = item.optString("narrative1")
|
||||||
|
val date = when (desc) {
|
||||||
|
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||||
|
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||||
|
else -> item.optString("bookingDate")
|
||||||
|
}
|
||||||
|
BankTransaction(
|
||||||
|
id = item.optString("id"),
|
||||||
|
date = date,
|
||||||
|
description = desc,
|
||||||
|
amount = item.optDouble("amount", 0.0),
|
||||||
|
currency = item.optString("currency"),
|
||||||
|
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
||||||
|
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "BML"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Pair(transactions, totalPages)
|
||||||
|
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchCardHistory(
|
||||||
|
session: BmlSession,
|
||||||
|
cardId: String,
|
||||||
|
accountDisplayName: String,
|
||||||
|
accountNumber: String,
|
||||||
|
month: String
|
||||||
|
): List<BankTransaction> {
|
||||||
|
val body = """{"card":"$cardId","month":"$month"}"""
|
||||||
|
.toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BML_BASE_URL/api/mobile/card/statement").post(body)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string() ?: return emptyList()
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||||
|
val result = mutableListOf<BankTransaction>()
|
||||||
|
|
||||||
|
val authDetails = payload.optJSONObject("outstanding")
|
||||||
|
?.optJSONArray("CardOutStdAuthDetails")
|
||||||
|
if (authDetails != null) {
|
||||||
|
for (i in 0 until authDetails.length()) {
|
||||||
|
val item = authDetails.getJSONObject(i)
|
||||||
|
result.add(BankTransaction(
|
||||||
|
id = "auth_${item.optString("TranApprCode")}_$i",
|
||||||
|
date = item.optString("DateTime"),
|
||||||
|
description = item.optString("TranDesc").trim(),
|
||||||
|
amount = item.optDouble("BillingAmount", 0.0),
|
||||||
|
currency = item.optString("BillingCcy", "MVR"),
|
||||||
|
counterpartyName = null,
|
||||||
|
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "BML_CARD"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val unbilled = payload.optJSONObject("unbilled")
|
||||||
|
?.optJSONArray("CardUnbillTxnDetails")
|
||||||
|
if (unbilled != null) {
|
||||||
|
for (i in 0 until unbilled.length()) {
|
||||||
|
val item = unbilled.getJSONObject(i)
|
||||||
|
result.add(BankTransaction(
|
||||||
|
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
||||||
|
date = item.optString("DateTime"),
|
||||||
|
description = item.optString("TranDesc").trim(),
|
||||||
|
amount = item.optDouble("BillingAmount", 0.0),
|
||||||
|
currency = item.optString("BillingCcy", "MVR"),
|
||||||
|
counterpartyName = null,
|
||||||
|
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "BML_CARD"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val statement = payload.optJSONArray("cardstatement")
|
||||||
|
if (statement != null) {
|
||||||
|
for (i in 0 until statement.length()) {
|
||||||
|
val item = statement.getJSONObject(i)
|
||||||
|
result.add(BankTransaction(
|
||||||
|
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||||
|
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||||
|
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||||
|
amount = -item.optDouble("TranAmount", 0.0),
|
||||||
|
currency = item.optString("TranCcy", "MVR"),
|
||||||
|
counterpartyName = null,
|
||||||
|
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "BML_CARD"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||||
|
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||||
|
return try {
|
||||||
|
val parts = narrative1.split(" ")
|
||||||
|
if (parts.size < 2) null
|
||||||
|
else {
|
||||||
|
val timePart = parts[1].take(4)
|
||||||
|
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||||
|
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||||
|
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||||
|
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||||
|
return try {
|
||||||
|
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||||
|
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,19 +7,15 @@ import okhttp3.FormBody
|
|||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class AuthExpiredException : Exception("Session expired")
|
class AuthExpiredException : Exception("Session expired")
|
||||||
@@ -29,9 +25,9 @@ class BmlLoginFlow {
|
|||||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||||
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
|
private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||||
private val APP_VERSION = "2.1.43.345"
|
private val APP_VERSION = "2.1.44.348"
|
||||||
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
@@ -53,14 +49,27 @@ class BmlLoginFlow {
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val apiClient = OkHttpClient.Builder()
|
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
private var codeVerifier: String = ""
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
private var codeChallenge: String = ""
|
||||||
.build()
|
private var deviceId: String = ""
|
||||||
|
|
||||||
/** Full login: returns a BmlSession and the account list. */
|
/** Profiles returned by the last successful [login] call. */
|
||||||
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
|
var lastProfiles: List<BmlProfile> = emptyList()
|
||||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
|
private set
|
||||||
|
|
||||||
|
// ─── Login ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs web authentication (login + TOTP) and returns the list of available profiles.
|
||||||
|
* Call [activateProfile] for each profile to obtain an access token + accounts.
|
||||||
|
*/
|
||||||
|
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
|
||||||
|
codeVerifier = generateCodeVerifier()
|
||||||
|
codeChallenge = generateCodeChallenge(codeVerifier)
|
||||||
|
deviceId = generateDeviceId()
|
||||||
|
|
||||||
|
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login")
|
Request.Builder().url("$BASE_URL/web/login")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
@@ -82,15 +91,14 @@ class BmlLoginFlow {
|
|||||||
loginResp.close()
|
loginResp.close()
|
||||||
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
||||||
|
|
||||||
// Step 3: GET 2FA page (refreshes blaze_session)
|
// Step 3: GET 2FA page (refreshes session cookies)
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login/2fa")
|
Request.Builder().url("$BASE_URL/web/login/2fa")
|
||||||
.header("X-XSRF-TOKEN", xsrf)
|
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
val xsrf2 = xsrfToken() ?: xsrf
|
val xsrf2 = xsrfToken() ?: xsrf
|
||||||
|
|
||||||
// Step 4: POST OTP
|
// Step 4: POST TOTP
|
||||||
val otp = Totp.generate(otpSeed)
|
val otp = Totp.generate(otpSeed)
|
||||||
val twoFaBody = JSONObject().apply {
|
val twoFaBody = JSONObject().apply {
|
||||||
put("code", otp)
|
put("code", otp)
|
||||||
@@ -104,18 +112,161 @@ class BmlLoginFlow {
|
|||||||
twoFaResp.close()
|
twoFaResp.close()
|
||||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
||||||
|
|
||||||
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
|
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
|
||||||
client.newCall(
|
// single-profile accounts skip the picker and redirect straight to /web/redirect with
|
||||||
|
// blaze_identity already set in the response cookies.
|
||||||
|
val profileResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/profile")
|
Request.Builder().url("$BASE_URL/web/profile")
|
||||||
.header("X-XSRF-TOKEN", xsrf2)
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val profileCode = profileResp.code
|
||||||
|
val profileLocation = profileResp.header("Location") ?: ""
|
||||||
|
val profileBody = profileResp.body?.string() ?: ""
|
||||||
|
profileResp.close()
|
||||||
|
|
||||||
|
lastProfiles = if (profileCode == 302) {
|
||||||
|
// Any 302 from GET /web/profile means the server auto-activated the sole profile
|
||||||
|
// and blaze_identity is already set — no profile picker shown.
|
||||||
|
// Use username as a stable temporary profileId (unique per login); it will be
|
||||||
|
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
|
||||||
|
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
|
||||||
|
} else {
|
||||||
|
parseProfiles(profileBody)
|
||||||
|
}
|
||||||
|
return lastProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile activation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates a profile in the current web session and returns the result.
|
||||||
|
*
|
||||||
|
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
|
||||||
|
* - Business profiles (profile_type="business") require SMS/email OTP; returns
|
||||||
|
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
|
||||||
|
* [requestBusinessOtp] + [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
|
||||||
|
// Single-profile accounts: server already activated during login() and set blaze_identity.
|
||||||
|
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
|
||||||
|
if (profile.autoActivated) {
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
return BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
val xsrf = xsrfToken()
|
||||||
|
val reqBuilder = Request.Builder()
|
||||||
|
.url("$BASE_URL/web/profile/${profile.profileId}")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT)
|
||||||
|
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
|
||||||
|
val resp = client.newCall(reqBuilder.build()).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
|
||||||
|
// Profile activated — blaze_identity cookie set in response headers.
|
||||||
|
// Any 302 that isn't to the business 2FA page means success.
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
code == 302 && "/web/profile/2fa/business" in location -> {
|
||||||
|
// Business profile: server requires SMS/email OTP
|
||||||
|
val channels = fetchBusinessOtpChannels()
|
||||||
|
BmlActivationResult.NeedsBusinessOtp(channels)
|
||||||
|
}
|
||||||
|
else -> throw Exception("Profile activation failed (HTTP $code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available OTP channels for the business 2FA page.
|
||||||
|
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
|
||||||
|
*/
|
||||||
|
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val body = resp.body?.string() ?: ""
|
||||||
|
resp.close()
|
||||||
|
return parseBusinessOtpChannels(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an OTP to [channel] for business profile activation.
|
||||||
|
* Must be called before [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun requestBusinessOtp(channel: String) {
|
||||||
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("code", "")
|
||||||
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
resp.close()
|
||||||
|
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the OTP and activates the business profile.
|
||||||
|
* Returns a new [BmlSession] and accounts on success.
|
||||||
|
* @throws Exception if the OTP is invalid (retry is allowed).
|
||||||
|
*/
|
||||||
|
fun submitBusinessOtp(
|
||||||
|
channel: String,
|
||||||
|
code: String,
|
||||||
|
profile: BmlProfile,
|
||||||
|
loginTag: String
|
||||||
|
): Pair<BmlSession, List<BankAccount>> {
|
||||||
|
// Refresh XSRF token before submitting
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
|
|
||||||
// Step 6: PKCE OAuth authorize → extract auth code
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
val codeVerifier = generateCodeVerifier()
|
val body = JSONObject().apply {
|
||||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
put("code", code)
|
||||||
val deviceId = generateDeviceId()
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
|
||||||
|
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
respCode == 302 ->
|
||||||
|
throw Exception("Invalid OTP — please try again")
|
||||||
|
else ->
|
||||||
|
throw Exception("Business OTP verification failed (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OAuth + account fetch ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
|
||||||
|
* Returns a fresh [BmlSession] and the profile's accounts.
|
||||||
|
*/
|
||||||
|
private fun doOAuthAndFetchAccounts(
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String,
|
||||||
|
profileId: String
|
||||||
|
): Pair<BmlSession, List<BankAccount>> {
|
||||||
val authorizeUrl = HttpUrl.Builder()
|
val authorizeUrl = HttpUrl.Builder()
|
||||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||||
.addPathSegments("internetbanking/oauth/authorize")
|
.addPathSegments("internetbanking/oauth/authorize")
|
||||||
@@ -135,14 +286,12 @@ class BmlLoginFlow {
|
|||||||
Request.Builder().url(authorizeUrl)
|
Request.Builder().url(authorizeUrl)
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val location = authorizeResp.header("Location")
|
||||||
authorizeResp.close()
|
authorizeResp.close()
|
||||||
|
|
||||||
val location = authorizeResp.header("Location")
|
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
|
||||||
?: throw Exception("OAuth authorize did not redirect")
|
?: throw Exception("OAuth authorize did not return auth code")
|
||||||
val authCode = Uri.parse(location).getQueryParameter("code")
|
|
||||||
?: throw Exception("No auth code in OAuth redirect")
|
|
||||||
|
|
||||||
// Step 7: Exchange auth code for access token
|
|
||||||
val tokenBody = FormBody.Builder()
|
val tokenBody = FormBody.Builder()
|
||||||
.add("Device-ID", deviceId)
|
.add("Device-ID", deviceId)
|
||||||
.add("code", authCode)
|
.add("code", authCode)
|
||||||
@@ -164,563 +313,95 @@ class BmlLoginFlow {
|
|||||||
val tokenObj = JSONObject(tokenJson)
|
val tokenObj = JSONObject(tokenJson)
|
||||||
val accessToken = tokenObj.optString("access_token")
|
val accessToken = tokenObj.optString("access_token")
|
||||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||||
|
val refreshToken = tokenObj.optString("refresh_token", "")
|
||||||
|
val expiresIn = tokenObj.optLong("expires_in", 0L)
|
||||||
|
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||||
|
|
||||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
|
||||||
val accounts = fetchAccounts(session, "bml_$username")
|
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
||||||
return Pair(session, accounts)
|
return Pair(session, accounts)
|
||||||
}
|
}
|
||||||
|
// ─── Token refresh ───────────────────────────────────────────────────────
|
||||||
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
|
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
|
||||||
val code = resp.code
|
|
||||||
val json = resp.body?.string()
|
|
||||||
resp.close()
|
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
|
||||||
return parseDashboard(json ?: return emptyList(), loginTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits")
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val code = resp.code
|
|
||||||
val json = resp.body?.string()
|
|
||||||
resp.close()
|
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
|
||||||
return parseForeignLimits(json ?: return emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
data class BmlUserInfo(
|
|
||||||
val fullName: String,
|
|
||||||
val email: String,
|
|
||||||
val mobile: String,
|
|
||||||
val customerId: String,
|
|
||||||
val idCard: String,
|
|
||||||
val birthdate: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute()
|
|
||||||
val json = resp.body?.string() ?: return null
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return null
|
|
||||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
|
||||||
BmlUserInfo(
|
|
||||||
fullName = user.optString("fullname").trim(),
|
|
||||||
email = user.optString("email").trim(),
|
|
||||||
mobile = user.optString("mobile_phone").trim(),
|
|
||||||
customerId = user.optString("customer_number").trim(),
|
|
||||||
idCard = user.optString("idcard").trim(),
|
|
||||||
birthdate = user.optString("birthdate").trim()
|
|
||||||
)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return null
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return null
|
|
||||||
val payload = root.optJSONObject("payload") ?: return null
|
|
||||||
val trnType = payload.optString("trnType", "")
|
|
||||||
val validationType = payload.optString("validationType", "")
|
|
||||||
if (validationType == "alias") {
|
|
||||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
|
||||||
BmlAccountValidation(
|
|
||||||
trnType = trnType,
|
|
||||||
validationType = validationType,
|
|
||||||
account = cdtrAcct.optString("Acct"),
|
|
||||||
originalInput = input,
|
|
||||||
name = payload.optString("contact_name").trim(),
|
|
||||||
alias = null,
|
|
||||||
currency = payload.optString("currency", "MVR"),
|
|
||||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BmlAccountValidation(
|
|
||||||
trnType = trnType,
|
|
||||||
validationType = validationType,
|
|
||||||
account = payload.optString("account"),
|
|
||||||
originalInput = input,
|
|
||||||
name = payload.optString("name"),
|
|
||||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
|
||||||
currency = payload.optString("currency", "MVR")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return null
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return null
|
|
||||||
BmlAccountValidation(
|
|
||||||
trnType = "DOT",
|
|
||||||
validationType = "MIB",
|
|
||||||
account = root.optString("account"),
|
|
||||||
originalInput = account,
|
|
||||||
name = root.optString("name"),
|
|
||||||
alias = null,
|
|
||||||
currency = "MVR",
|
|
||||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveContact(
|
|
||||||
session: BmlSession,
|
|
||||||
contactType: String,
|
|
||||||
account: String,
|
|
||||||
alias: String,
|
|
||||||
currency: String? = null,
|
|
||||||
name: String? = null,
|
|
||||||
swift: String? = null
|
|
||||||
): Boolean {
|
|
||||||
val bodyObj = JSONObject().apply {
|
|
||||||
put("contact_type", contactType)
|
|
||||||
put("account", account)
|
|
||||||
put("alias", alias)
|
|
||||||
if (currency != null) put("currency", currency)
|
|
||||||
if (name != null) put("name", name)
|
|
||||||
if (swift != null) put("swift", swift)
|
|
||||||
}
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/contacts")
|
|
||||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return false
|
|
||||||
resp.close()
|
|
||||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
|
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
|
||||||
val json = resp.body?.string() ?: return emptyList()
|
|
||||||
resp.close()
|
|
||||||
return parseContacts(json, loginId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
* Uses the saved refresh token to obtain a new access token without re-login.
|
||||||
|
* Returns a new [BmlSession] with updated tokens.
|
||||||
*/
|
*/
|
||||||
fun initiateTransfer(
|
fun refreshSession(session: BmlSession): BmlSession {
|
||||||
session: BmlSession,
|
val body = FormBody.Builder()
|
||||||
debitAccount: String,
|
.add("grant_type", "refresh_token")
|
||||||
creditAccount: String,
|
.add("refresh_token", session.refreshToken)
|
||||||
amount: Double,
|
.add("client_id", CLIENT_ID)
|
||||||
transferType: String,
|
.add("Device-ID", session.deviceId)
|
||||||
currency: String,
|
.add("User-Agent", APP_USER_AGENT)
|
||||||
bank: String? = null
|
.add("x-app-version", APP_VERSION)
|
||||||
): Boolean {
|
|
||||||
val jo = JSONObject().apply {
|
|
||||||
put("debitAccount", debitAccount)
|
|
||||||
put("creditAccount", creditAccount)
|
|
||||||
put("debitAmount", amount)
|
|
||||||
put("transfertype", transferType)
|
|
||||||
put("currency", currency)
|
|
||||||
put("channel", "token")
|
|
||||||
if (bank != null) put("bank", bank)
|
|
||||||
}
|
|
||||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$BASE_URL/api/mobile/transfer")
|
|
||||||
.post(body)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.build()
|
.build()
|
||||||
return apiClient.newCall(request).execute().use { response ->
|
val resp = newBmlApiClient().newCall(
|
||||||
val bodyStr = response.body?.string() ?: return@use false
|
Request.Builder().url("$BASE_URL/oauth/token").post(body)
|
||||||
try {
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
val json = JSONObject(bodyStr)
|
|
||||||
json.optBoolean("success") && json.optInt("code") == 22
|
|
||||||
} catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
|
|
||||||
*/
|
|
||||||
fun confirmTransfer(
|
|
||||||
session: BmlSession,
|
|
||||||
debitAccount: String,
|
|
||||||
creditAccount: String,
|
|
||||||
amount: Double,
|
|
||||||
transferType: String,
|
|
||||||
currency: String,
|
|
||||||
otp: String,
|
|
||||||
remarks: String = "",
|
|
||||||
bank: String? = null
|
|
||||||
): BmlTransferResult {
|
|
||||||
val jo = JSONObject().apply {
|
|
||||||
put("debitAccount", debitAccount)
|
|
||||||
put("creditAccount", creditAccount)
|
|
||||||
put("debitAmount", amount)
|
|
||||||
put("transfertype", transferType)
|
|
||||||
put("currency", currency)
|
|
||||||
put("channel", "token")
|
|
||||||
put("otp", otp)
|
|
||||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
|
||||||
if (bank != null) put("bank", bank)
|
|
||||||
}
|
|
||||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$BASE_URL/api/mobile/transfer")
|
|
||||||
.post(body)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.build()
|
|
||||||
return apiClient.newCall(request).execute().use { response ->
|
|
||||||
val bodyStr = response.body?.string()
|
|
||||||
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
|
||||||
try {
|
|
||||||
val json = JSONObject(bodyStr)
|
|
||||||
if (!json.optBoolean("success")) {
|
|
||||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
|
||||||
} else {
|
|
||||||
val payload = json.optJSONObject("payload")
|
|
||||||
BmlTransferResult(
|
|
||||||
success = true,
|
|
||||||
reference = payload?.optString("reference") ?: "",
|
|
||||||
timestamp = payload?.optString("timestamp") ?: "",
|
|
||||||
message = json.optString("message")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
|
||||||
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$BASE_URL/api/mobile/contacts/$contactId")
|
|
||||||
.post(body)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.build()
|
|
||||||
return apiClient.newCall(request).execute().use { response ->
|
|
||||||
val bodyStr = response.body?.string() ?: return@use false
|
|
||||||
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
|
||||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
|
||||||
return try {
|
|
||||||
val parts = narrative1.split(" ")
|
|
||||||
if (parts.size < 2) null
|
|
||||||
else {
|
|
||||||
val timePart = parts[1].take(4)
|
|
||||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
|
||||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
|
||||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
|
||||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
|
||||||
return try {
|
|
||||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
|
||||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches paginated transaction history for a BML CASA account.
|
|
||||||
* @return Pair of (transactions, totalPages)
|
|
||||||
*/
|
|
||||||
fun fetchAccountHistory(
|
|
||||||
session: BmlSession,
|
|
||||||
accountId: String,
|
|
||||||
accountDisplayName: String,
|
|
||||||
accountNumber: String,
|
|
||||||
page: Int
|
|
||||||
): Pair<List<Transaction>, Int> {
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.build()
|
|
||||||
).execute()
|
).execute()
|
||||||
val code = resp.code
|
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
|
||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
val obj = JSONObject(json)
|
||||||
return try {
|
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
|
||||||
val root = JSONObject(json)
|
?: throw Exception("Token refresh failed")
|
||||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
|
||||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
val expiresIn = obj.optLong("expires_in", 0L)
|
||||||
val totalPages = payload.optInt("totalPages", 0)
|
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
|
||||||
val transactions = (0 until history.length()).map { i ->
|
|
||||||
val item = history.getJSONObject(i)
|
|
||||||
val desc = item.optString("description").trim()
|
|
||||||
val narrative1 = item.optString("narrative1")
|
|
||||||
val date = when (desc) {
|
|
||||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
|
||||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
|
||||||
else -> item.optString("bookingDate")
|
|
||||||
}
|
|
||||||
Transaction(
|
|
||||||
id = item.optString("id"),
|
|
||||||
date = date,
|
|
||||||
description = desc,
|
|
||||||
amount = item.optDouble("amount", 0.0),
|
|
||||||
currency = item.optString("currency"),
|
|
||||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
|
||||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Pair(transactions, totalPages)
|
|
||||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
|
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
|
||||||
* Returns combined outstanding authorizations + settled statement entries.
|
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
|
||||||
*/
|
*/
|
||||||
fun fetchCardHistory(
|
private fun extractInertiaJson(html: String): String? {
|
||||||
session: BmlSession,
|
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
|
||||||
cardId: String,
|
return match.groupValues[1]
|
||||||
accountDisplayName: String,
|
.replace(""", "\"")
|
||||||
accountNumber: String,
|
.replace("&", "&")
|
||||||
month: String
|
.replace("'", "'")
|
||||||
): List<Transaction> {
|
.replace("<", "<")
|
||||||
val body = """{"card":"$cardId","month":"$month"}"""
|
.replace(">", ">")
|
||||||
.toRequestBody("application/json".toMediaType())
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val code = resp.code
|
|
||||||
val json = resp.body?.string() ?: return emptyList()
|
|
||||||
resp.close()
|
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
|
||||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
|
||||||
val result = mutableListOf<Transaction>()
|
|
||||||
|
|
||||||
// Outstanding authorizations
|
|
||||||
val authDetails = payload.optJSONObject("outstanding")
|
|
||||||
?.optJSONArray("CardOutStdAuthDetails")
|
|
||||||
if (authDetails != null) {
|
|
||||||
for (i in 0 until authDetails.length()) {
|
|
||||||
val item = authDetails.getJSONObject(i)
|
|
||||||
result.add(Transaction(
|
|
||||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
|
||||||
date = item.optString("DateTime"),
|
|
||||||
description = item.optString("TranDesc").trim(),
|
|
||||||
amount = item.optDouble("BillingAmount", 0.0),
|
|
||||||
currency = item.optString("BillingCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settled statement entries
|
|
||||||
val statement = payload.optJSONArray("cardstatement")
|
|
||||||
if (statement != null) {
|
|
||||||
for (i in 0 until statement.length()) {
|
|
||||||
val item = statement.getJSONObject(i)
|
|
||||||
result.add(Transaction(
|
|
||||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
|
||||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
|
||||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
|
||||||
amount = -item.optDouble("TranAmount", 0.0),
|
|
||||||
currency = item.optString("TranCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} catch (_: Exception) { emptyList() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun apiRequest(session: BmlSession, url: String) =
|
private fun parseProfiles(html: String): List<BmlProfile> {
|
||||||
Request.Builder().url(url)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
|
||||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
|
||||||
|
|
||||||
val casaAccounts = mutableListOf<MibAccount>()
|
|
||||||
val prepaidCards = mutableListOf<MibAccount>()
|
|
||||||
|
|
||||||
for (i in 0 until dashboard.length()) {
|
|
||||||
val item = dashboard.getJSONObject(i)
|
|
||||||
val currency = item.optString("currency", "MVR")
|
|
||||||
val accountType = item.optString("account_type", "CASA")
|
|
||||||
val product = item.optString("product")
|
|
||||||
val accountNumber = item.optString("account")
|
|
||||||
val status = item.optString("account_status", "Active")
|
|
||||||
|
|
||||||
val internalId = item.optString("id", "")
|
|
||||||
|
|
||||||
if (accountType == "CASA") {
|
|
||||||
val available = item.optDouble("availableBalance", 0.0)
|
|
||||||
casaAccounts.add(MibAccount(
|
|
||||||
profileName = "Personal",
|
|
||||||
profileType = "BML",
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountBriefName = item.optString("alias"),
|
|
||||||
currencyName = currency,
|
|
||||||
accountTypeName = product,
|
|
||||||
availableBalance = "%.2f".format(available),
|
|
||||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
|
||||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
|
||||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
|
||||||
statusDesc = status,
|
|
||||||
profileImageHash = null,
|
|
||||||
loginTag = loginTag,
|
|
||||||
internalId = internalId
|
|
||||||
))
|
|
||||||
} else if (accountType == "Card") {
|
|
||||||
val isVisible = item.optBoolean("account_visible", false)
|
|
||||||
if (!isVisible) continue // debit cards and other hidden cards — skip
|
|
||||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
|
||||||
val cardBalance = item.optJSONObject("cardBalance")
|
|
||||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
|
||||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
|
||||||
prepaidCards.add(MibAccount(
|
|
||||||
profileName = "Personal",
|
|
||||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountBriefName = item.optString("alias").ifBlank { product },
|
|
||||||
currencyName = currency,
|
|
||||||
accountTypeName = product,
|
|
||||||
availableBalance = "%.2f".format(available),
|
|
||||||
currentBalance = "%.2f".format(current),
|
|
||||||
blockedAmount = "0.00",
|
|
||||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
|
||||||
statusDesc = status,
|
|
||||||
profileImageHash = null,
|
|
||||||
loginTag = loginTag,
|
|
||||||
internalId = internalId
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return casaAccounts + prepaidCards
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
|
||||||
return try {
|
return try {
|
||||||
|
val json = extractInertiaJson(html) ?: html
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
val profiles = props.optJSONArray("profiles") ?: return emptyList()
|
||||||
(0 until payload.length()).map { i ->
|
(0 until profiles.length()).mapNotNull { i ->
|
||||||
val item = payload.getJSONObject(i)
|
val p = profiles.getJSONObject(i)
|
||||||
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
|
||||||
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
BmlProfile(
|
||||||
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
profileId = p.optString("profile_id"),
|
||||||
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
name = p.optString("name"),
|
||||||
BmlForeignLimit(
|
type = p.optString("type"),
|
||||||
type = item.optString("type", "Debit"),
|
profileType = profileObj.optString("profile_type", "default")
|
||||||
used = item.optDouble("used", 0.0),
|
|
||||||
totalLimit = item.optDouble("totalLimit", 0.0),
|
|
||||||
generalCap = item.optDouble("generalCap", 0.0),
|
|
||||||
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
|
||||||
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
|
||||||
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
|
||||||
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
|
||||||
atmRemaining = atm.optDouble("remaining", 0.0),
|
|
||||||
atmLimit = atm.optDouble("limit", 0.0),
|
|
||||||
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
|
||||||
ecomLimit = ecom.optDouble("limit", 0.0),
|
|
||||||
posRemaining = pos.optDouble("remaining", 0.0),
|
|
||||||
posLimit = pos.optDouble("limit", 0.0)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
|
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
|
||||||
val root = JSONObject(json)
|
return try {
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
val json = extractInertiaJson(html) ?: html
|
||||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
val root = JSONObject(json)
|
||||||
val result = mutableListOf<MibBeneficiary>()
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
for (i in 0 until payload.length()) {
|
val channels = props.optJSONArray("channels") ?: return emptyList()
|
||||||
val item = payload.getJSONObject(i)
|
(0 until channels.length()).map { i ->
|
||||||
val account = item.optString("account", "")
|
val c = channels.getJSONObject(i)
|
||||||
if (account.isBlank()) continue
|
BmlOtpChannel(
|
||||||
result.add(MibBeneficiary(
|
channel = c.optString("channel"),
|
||||||
benefNo = "bml_${item.optInt("id")}",
|
description = c.optString("description"),
|
||||||
benefName = item.optString("name"),
|
masked = c.optString("masked")
|
||||||
benefNickName = item.optString("alias", item.optString("name")),
|
)
|
||||||
benefAccount = account,
|
}
|
||||||
benefType = "I",
|
} catch (_: Exception) { emptyList() }
|
||||||
bankColor = "#0066A1",
|
|
||||||
benefBankName = "Bank of Maldives",
|
|
||||||
bankCode = "",
|
|
||||||
benefStatus = item.optString("status", "S"),
|
|
||||||
transferCyDesc = item.optString("currency", "MVR"),
|
|
||||||
customerImgHash = null,
|
|
||||||
benefCategoryId = "BML",
|
|
||||||
profileId = loginId
|
|
||||||
))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun xsrfToken(): String? =
|
private fun xsrfToken(): String? =
|
||||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||||
|
|
||||||
@@ -746,4 +427,5 @@ class BmlLoginFlow {
|
|||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
data class BmlSession(
|
data class BmlSession(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val deviceId: String
|
val deviceId: String,
|
||||||
|
val refreshToken: String = "",
|
||||||
|
val expiresAt: Long = 0L // Unix millis; 0 = unknown
|
||||||
|
) {
|
||||||
|
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BmlProfile(
|
||||||
|
val profileId: String,
|
||||||
|
val name: String,
|
||||||
|
val type: String, // "Profile" (personal) or "Business"
|
||||||
|
val profileType: String, // "default" or "business"
|
||||||
|
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlOtpChannel(
|
||||||
|
val channel: String,
|
||||||
|
val description: String,
|
||||||
|
val masked: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class BmlActivationResult {
|
||||||
|
data class Success(
|
||||||
|
val session: BmlSession,
|
||||||
|
val accounts: List<BankAccount>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
data class NeedsBusinessOtp(
|
||||||
|
val channels: List<BmlOtpChannel>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
}
|
||||||
|
|
||||||
data class BmlAccountValidation(
|
data class BmlAccountValidation(
|
||||||
val trnType: String, // IAT, QTR, DOT
|
val trnType: String, // IAT, QTR, DOT
|
||||||
val validationType: String, // BML, alias, MIB
|
val validationType: String, // BML, alias, MIB
|
||||||
@@ -24,6 +54,18 @@ data class BmlTransferResult(
|
|||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlLoanDetail(
|
||||||
|
val loanAmount: Double,
|
||||||
|
val outstandingAmt: Double, // negative as returned by API
|
||||||
|
val repayAmount: Double,
|
||||||
|
val intRate: Double,
|
||||||
|
val loanStatus: String,
|
||||||
|
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
|
||||||
|
val endDate: String,
|
||||||
|
val noOfRepayOverdue: Int,
|
||||||
|
val overdueAmount: Double
|
||||||
|
)
|
||||||
|
|
||||||
data class BmlForeignLimit(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
val used: Double,
|
||||||
|
|||||||
100
app/src/main/java/sh/sar/basedbank/api/bml/BmlTransferClient.kt
Normal file
100
app/src/main/java/sh/sar/basedbank/api/bml/BmlTransferClient.kt
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class BmlTransferClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/** Step 1: initiate the transfer (triggers OTP). Returns true if the server accepted it. */
|
||||||
|
fun initiateTransfer(
|
||||||
|
session: BmlSession,
|
||||||
|
debitAccount: String,
|
||||||
|
creditAccount: String,
|
||||||
|
amount: Double,
|
||||||
|
transferType: String,
|
||||||
|
currency: String,
|
||||||
|
bank: String? = null,
|
||||||
|
channel: String = "token"
|
||||||
|
): Boolean {
|
||||||
|
val jo = JSONObject().apply {
|
||||||
|
put("debitAccount", debitAccount)
|
||||||
|
put("creditAccount", creditAccount)
|
||||||
|
put("debitAmount", amount)
|
||||||
|
put("transfertype", transferType)
|
||||||
|
put("currency", currency)
|
||||||
|
put("channel", channel)
|
||||||
|
if (bank != null) put("bank", bank)
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||||
|
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val bodyStr = response.body?.string() ?: return@use false
|
||||||
|
try {
|
||||||
|
val json = JSONObject(bodyStr)
|
||||||
|
json.optBoolean("success") && json.optInt("code") == 22
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 2: confirm with OTP. Returns a [BmlTransferResult] with success/reference/error. */
|
||||||
|
fun confirmTransfer(
|
||||||
|
session: BmlSession,
|
||||||
|
debitAccount: String,
|
||||||
|
creditAccount: String,
|
||||||
|
amount: Double,
|
||||||
|
transferType: String,
|
||||||
|
currency: String,
|
||||||
|
otp: String,
|
||||||
|
remarks: String = "",
|
||||||
|
bank: String? = null,
|
||||||
|
channel: String = "token"
|
||||||
|
): BmlTransferResult {
|
||||||
|
val jo = JSONObject().apply {
|
||||||
|
put("debitAccount", debitAccount)
|
||||||
|
put("creditAccount", creditAccount)
|
||||||
|
put("debitAmount", amount)
|
||||||
|
put("transfertype", transferType)
|
||||||
|
put("currency", currency)
|
||||||
|
put("channel", channel)
|
||||||
|
put("otp", otp)
|
||||||
|
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||||
|
if (bank != null) put("bank", bank)
|
||||||
|
}
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||||
|
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val bodyStr = response.body?.string()
|
||||||
|
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
||||||
|
try {
|
||||||
|
val json = JSONObject(bodyStr)
|
||||||
|
if (!json.optBoolean("success")) {
|
||||||
|
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
||||||
|
} else {
|
||||||
|
val payload = json.optJSONObject("payload")
|
||||||
|
BmlTransferResult(
|
||||||
|
success = true,
|
||||||
|
reference = payload?.optString("reference") ?: "",
|
||||||
|
timestamp = payload?.optString("timestamp") ?: "",
|
||||||
|
message = json.optString("message")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class BmlValidateClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BML_BASE_URL/api/mobile/validate/account/$input")
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return null
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return null
|
||||||
|
val payload = root.optJSONObject("payload") ?: return null
|
||||||
|
val trnType = payload.optString("trnType", "")
|
||||||
|
val validationType = payload.optString("validationType", "")
|
||||||
|
if (validationType == "alias") {
|
||||||
|
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
||||||
|
BmlAccountValidation(
|
||||||
|
trnType = trnType,
|
||||||
|
validationType = validationType,
|
||||||
|
account = cdtrAcct.optString("Acct"),
|
||||||
|
originalInput = input,
|
||||||
|
name = payload.optString("contact_name").trim(),
|
||||||
|
alias = null,
|
||||||
|
currency = payload.optString("currency", "MVR"),
|
||||||
|
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BmlAccountValidation(
|
||||||
|
trnType = trnType,
|
||||||
|
validationType = validationType,
|
||||||
|
account = payload.optString("account"),
|
||||||
|
originalInput = input,
|
||||||
|
name = payload.optString("name"),
|
||||||
|
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
||||||
|
currency = payload.optString("currency", "MVR")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BML_BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return null
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return null
|
||||||
|
BmlAccountValidation(
|
||||||
|
trnType = "DOT",
|
||||||
|
validationType = "MIB",
|
||||||
|
account = root.optString("account"),
|
||||||
|
originalInput = account,
|
||||||
|
name = root.optString("name"),
|
||||||
|
alias = null,
|
||||||
|
currency = "MVR",
|
||||||
|
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class FahipayAccountClient {
|
||||||
|
|
||||||
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
|
private val UA = "okhttp/4.12.0"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun Request.Builder.auth(session: FahipaySession): Request.Builder = this
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||||
|
.header("content-type", "multipart/form-data")
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
|
||||||
|
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||||
|
.auth(session).build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||||
|
resp.close()
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||||
|
return FahipayUserProfile(
|
||||||
|
fullName = obj.optString("fullname").trim(),
|
||||||
|
email = obj.optString("email").trim(),
|
||||||
|
mobile = obj.optString("mobile").trim(),
|
||||||
|
nid = obj.optString("nid").trim(),
|
||||||
|
profileId = obj.optString("profileID").trim(),
|
||||||
|
walletAccount = props.optString("acc", ""),
|
||||||
|
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetchBalance(session: FahipaySession): Double {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||||
|
.auth(session).build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return 0.0
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||||
|
} catch (_: Exception) { 0.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): BankAccount =
|
||||||
|
BankAccount(
|
||||||
|
bank = "FAHIPAY",
|
||||||
|
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||||
|
profileType = "FAHIPAY",
|
||||||
|
accountNumber = profile.walletAccount,
|
||||||
|
accountBriefName = "Fahipay Wallet",
|
||||||
|
currencyName = "MVR",
|
||||||
|
accountTypeName = "Digital Wallet",
|
||||||
|
availableBalance = "%.2f".format(balance),
|
||||||
|
currentBalance = "%.2f".format(balance),
|
||||||
|
blockedAmount = "0.00",
|
||||||
|
mvrBalance = "%.2f".format(balance),
|
||||||
|
statusDesc = "Active",
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = loginTag,
|
||||||
|
internalId = profile.profileId
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class FahipayContactsClient {
|
||||||
|
|
||||||
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
|
private val UA = "okhttp/4.12.0"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||||
|
val endpoints = listOf(
|
||||||
|
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||||
|
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||||
|
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||||
|
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||||
|
)
|
||||||
|
val result = mutableListOf<FahipayContactGroup>()
|
||||||
|
for ((catId, label, page) in endpoints) {
|
||||||
|
try {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: continue
|
||||||
|
resp.close()
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val groupObj = obj.optJSONObject(page) ?: continue
|
||||||
|
val contacts = mutableListOf<BankContact>()
|
||||||
|
for (key in groupObj.keys()) {
|
||||||
|
val entry = groupObj.getJSONObject(key)
|
||||||
|
val number = entry.optString("number")
|
||||||
|
val name = entry.optString("name").trim().ifBlank { number }
|
||||||
|
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||||
|
contacts.add(BankContact(
|
||||||
|
benefNo = "fp_${page}_$number",
|
||||||
|
benefName = "",
|
||||||
|
benefNickName = name,
|
||||||
|
benefAccount = number,
|
||||||
|
benefType = "FAHIPAY",
|
||||||
|
bankColor = "#FF6B00",
|
||||||
|
benefBankName = label,
|
||||||
|
bankCode = "",
|
||||||
|
benefStatus = "",
|
||||||
|
transferCyDesc = "",
|
||||||
|
customerImgHash = null,
|
||||||
|
benefCategoryId = catId,
|
||||||
|
profileId = ""
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class FahipayHistoryClient {
|
||||||
|
|
||||||
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
|
private val UA = "okhttp/4.12.0"
|
||||||
|
private val PAGE_SIZE = 15
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fetchHistory(
|
||||||
|
session: FahipaySession,
|
||||||
|
accountDisplayName: String,
|
||||||
|
accountNumber: String,
|
||||||
|
start: Int
|
||||||
|
): Pair<List<BankTransaction>, Int> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder()
|
||||||
|
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||||
|
.header("content-type", "multipart/form-data")
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
|
resp.close()
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val total = obj.optInt("total", 0)
|
||||||
|
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||||
|
val list = (0 until entries.length()).map { i ->
|
||||||
|
val e = entries.getJSONObject(i)
|
||||||
|
BankTransaction(
|
||||||
|
id = e.optString("transaction"),
|
||||||
|
date = e.optString("date"),
|
||||||
|
description = e.optString("name").trim(),
|
||||||
|
amount = e.optDouble("amount", 0.0),
|
||||||
|
currency = "MVR",
|
||||||
|
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||||
|
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
accountDisplayName = accountDisplayName,
|
||||||
|
source = "FAHIPAY",
|
||||||
|
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Pair(list, total)
|
||||||
|
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,6 @@ import okhttp3.Request
|
|||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@@ -21,8 +17,6 @@ class FahipayLoginFlow {
|
|||||||
|
|
||||||
private val BASE_URL = "https://fahipay.mv"
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
private val UA_OKHTTP = "okhttp/4.12.0"
|
|
||||||
private val PAGE_SIZE = 15
|
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
@@ -144,164 +138,6 @@ class FahipayLoginFlow {
|
|||||||
?: throw Exception("No authID in OTP response")
|
?: throw Exception("No authID in OTP response")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("content-type", "multipart/form-data")
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
|
||||||
resp.close()
|
|
||||||
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
|
||||||
return FahipayUserProfile(
|
|
||||||
fullName = obj.optString("fullname").trim(),
|
|
||||||
email = obj.optString("email").trim(),
|
|
||||||
mobile = obj.optString("mobile").trim(),
|
|
||||||
nid = obj.optString("nid").trim(),
|
|
||||||
profileId = obj.optString("profileID").trim(),
|
|
||||||
walletAccount = props.optString("acc", ""),
|
|
||||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchBalance(session: FahipaySession): Double {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("content-type", "multipart/form-data")
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return 0.0
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
|
||||||
} catch (_: Exception) { 0.0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
|
|
||||||
MibAccount(
|
|
||||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
|
||||||
profileType = "FAHIPAY",
|
|
||||||
accountNumber = profile.walletAccount,
|
|
||||||
accountBriefName = "Fahipay Wallet",
|
|
||||||
currencyName = "MVR",
|
|
||||||
accountTypeName = "Digital Wallet",
|
|
||||||
availableBalance = "%.2f".format(balance),
|
|
||||||
currentBalance = "%.2f".format(balance),
|
|
||||||
blockedAmount = "0.00",
|
|
||||||
mvrBalance = "%.2f".format(balance),
|
|
||||||
statusDesc = "Active",
|
|
||||||
profileImageHash = null,
|
|
||||||
loginTag = loginTag,
|
|
||||||
internalId = profile.profileId
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches paginated activity history.
|
|
||||||
* @param start offset (0-based)
|
|
||||||
* @return Pair of (transactions, total count)
|
|
||||||
*/
|
|
||||||
fun fetchHistory(
|
|
||||||
session: FahipaySession,
|
|
||||||
accountDisplayName: String,
|
|
||||||
accountNumber: String,
|
|
||||||
start: Int
|
|
||||||
): Pair<List<Transaction>, Int> {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder()
|
|
||||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("content-type", "multipart/form-data")
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
val total = obj.optInt("total", 0)
|
|
||||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
|
||||||
val list = (0 until entries.length()).map { i ->
|
|
||||||
val e = entries.getJSONObject(i)
|
|
||||||
Transaction(
|
|
||||||
id = e.optString("transaction"),
|
|
||||||
date = e.optString("date"),
|
|
||||||
description = e.optString("name").trim(),
|
|
||||||
amount = e.optDouble("amount", 0.0),
|
|
||||||
currency = "MVR",
|
|
||||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
|
||||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "FAHIPAY",
|
|
||||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Pair(list, total)
|
|
||||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches Fahipay saved favourites for the 4 service groups.
|
|
||||||
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
|
|
||||||
* Groups with no valid entries are omitted.
|
|
||||||
*/
|
|
||||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
|
||||||
val endpoints = listOf(
|
|
||||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
|
||||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
|
||||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
|
||||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
|
||||||
)
|
|
||||||
val result = mutableListOf<FahipayContactGroup>()
|
|
||||||
for ((catId, label, page) in endpoints) {
|
|
||||||
try {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder()
|
|
||||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: continue
|
|
||||||
resp.close()
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
|
|
||||||
val groupObj = obj.optJSONObject(page) ?: continue
|
|
||||||
val contacts = mutableListOf<MibBeneficiary>()
|
|
||||||
for (key in groupObj.keys()) {
|
|
||||||
val entry = groupObj.getJSONObject(key)
|
|
||||||
val number = entry.optString("number")
|
|
||||||
val name = entry.optString("name").trim().ifBlank { number }
|
|
||||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
|
||||||
contacts.add(MibBeneficiary(
|
|
||||||
benefNo = "fp_${page}_$number",
|
|
||||||
benefName = "",
|
|
||||||
benefNickName = name,
|
|
||||||
benefAccount = number,
|
|
||||||
benefType = "FAHIPAY",
|
|
||||||
bankColor = "#FF6B00",
|
|
||||||
benefBankName = label,
|
|
||||||
bankCode = "",
|
|
||||||
benefStatus = "",
|
|
||||||
transferCyDesc = "",
|
|
||||||
customerImgHash = null,
|
|
||||||
benefCategoryId = catId,
|
|
||||||
profileId = ""
|
|
||||||
))
|
|
||||||
}
|
|
||||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||||
"device[available]" to "true",
|
"device[available]" to "true",
|
||||||
"device[platform]" to "Android",
|
"device[platform]" to "Android",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package sh.sar.basedbank.api.fahipay
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
|
||||||
data class FahipaySession(
|
data class FahipaySession(
|
||||||
val authId: String,
|
val authId: String,
|
||||||
val sessionCookie: String
|
val sessionCookie: String
|
||||||
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
|
|||||||
data class FahipayContactGroup(
|
data class FahipayContactGroup(
|
||||||
val categoryId: String,
|
val categoryId: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary>
|
val contacts: List<BankContact>
|
||||||
)
|
)
|
||||||
|
|||||||
62
app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt
Normal file
62
app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class MibCardsClient {
|
||||||
|
|
||||||
|
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun cookieHeader(session: MibSession) =
|
||||||
|
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||||
|
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
|
|
||||||
|
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("name", "")
|
||||||
|
.add("start", "1")
|
||||||
|
.add("end", "50")
|
||||||
|
.add("includeCount", "1")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
||||||
|
.post(body)
|
||||||
|
.header("Cookie", cookieHeader(session))
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Origin", BASE_WV_URL)
|
||||||
|
.header("Referer", "$BASE_WV_URL//debitCards?dashurl=1")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val bodyStr = response.body?.string() ?: return emptyList()
|
||||||
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return emptyList() }
|
||||||
|
if (!json.optBoolean("success")) return emptyList()
|
||||||
|
val data = json.optJSONArray("data") ?: return emptyList()
|
||||||
|
(0 until data.length()).map { i ->
|
||||||
|
val item = data.getJSONObject(i)
|
||||||
|
MibCard(
|
||||||
|
cardId = item.optString("cardId"),
|
||||||
|
maskedCardNumber = item.optString("maskedCardNumber"),
|
||||||
|
cardStatus = item.optString("cardStatus"),
|
||||||
|
cardType = item.optString("cardType"),
|
||||||
|
cardTypeDesc = item.optString("cardTypeDesc"),
|
||||||
|
customerId = item.optString("customerId"),
|
||||||
|
phoneNumber = item.optString("phoneNumber"),
|
||||||
|
cardHolderName = item.optString("cardHolderName"),
|
||||||
|
loginTag = loginTag
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
||||||
|
|
||||||
// Stored after login so the session can be silently recovered on 419
|
// Stored after login so the session can be silently recovered on 419
|
||||||
|
@Volatile private var loginId: String = ""
|
||||||
@Volatile private var storedUsername: String? = null
|
@Volatile private var storedUsername: String? = null
|
||||||
@Volatile private var storedPasswordHash: String? = null
|
@Volatile private var storedPasswordHash: String? = null
|
||||||
@Volatile private var storedOtpSeed: String? = null
|
@Volatile private var storedOtpSeed: String? = null
|
||||||
@@ -58,11 +59,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
* Returns list of accounts from all profiles on success.
|
* Returns list of accounts from all profiles on success.
|
||||||
*/
|
*/
|
||||||
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
||||||
|
loginId = username
|
||||||
storedUsername = username
|
storedUsername = username
|
||||||
storedPasswordHash = passwordHash
|
storedPasswordHash = passwordHash
|
||||||
storedOtpSeed = otpSeed
|
storedOtpSeed = otpSeed
|
||||||
val appId = getOrCreateAppId()
|
val appId = getOrCreateAppId()
|
||||||
val keys = credentialStore.loadMibKeys()
|
val keys = credentialStore.loadMibKeys(loginId)
|
||||||
|
|
||||||
return if (keys != null) {
|
return if (keys != null) {
|
||||||
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
||||||
@@ -106,7 +108,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
||||||
val key1 = keyData.getString("key1")
|
val key1 = keyData.getString("key1")
|
||||||
val key2 = keyData.getString("key2")
|
val key2 = keyData.getString("key2")
|
||||||
credentialStore.saveMibKeys(key1, key2)
|
credentialStore.saveMibKeys(loginId, key1, key2)
|
||||||
|
|
||||||
return regularLogin(username, passwordHash, appId, key1, key2)
|
return regularLogin(username, passwordHash, appId, key1, key2)
|
||||||
}
|
}
|
||||||
@@ -136,10 +138,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val profiles = parseProfiles(loginResp)
|
val profiles = parseProfiles(loginResp)
|
||||||
|
|
||||||
lastSession = session2
|
lastSession = session2
|
||||||
lastProfiles = profiles
|
lastProfiles = profiles // keep ALL profiles so settings can show them all
|
||||||
return fetchAllProfiles(session2, profiles, "mib_$username")
|
|
||||||
|
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
|
||||||
|
|
||||||
|
// When the server already selected the profile and returned balances in A41
|
||||||
|
// (single-profile case: profileSelected=true), use those accounts directly
|
||||||
|
// without making an extra P47 call (which the server ignores or rejects).
|
||||||
|
if (loginResp.optBoolean("profileSelected", false)) {
|
||||||
|
val a41Balances = loginResp.optJSONArray("accountBalance")
|
||||||
|
if (a41Balances != null && a41Balances.length() > 0) {
|
||||||
|
val selectedId = loginResp.optString("selectedProfileId")
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == selectedId }
|
||||||
|
?: profiles.firstOrNull()
|
||||||
|
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
|
||||||
|
val allAccounts = mutableListOf<MibAccount>()
|
||||||
|
for (i in 0 until a41Balances.length()) {
|
||||||
|
val a = a41Balances.getJSONObject(i)
|
||||||
|
allAccounts.add(
|
||||||
|
MibAccount(
|
||||||
|
bank = "MIB",
|
||||||
|
profileName = profile.name,
|
||||||
|
profileType = profile.profileType,
|
||||||
|
cifType = profile.cifType,
|
||||||
|
accountNumber = a.optString("accountNumber"),
|
||||||
|
accountBriefName = a.optString("accountBriefName"),
|
||||||
|
currencyName = a.optString("currencyName"),
|
||||||
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
|
availableBalance = a.optString("availableBalance"),
|
||||||
|
currentBalance = a.optString("currentBalance"),
|
||||||
|
blockedAmount = a.optString("blockedAmount"),
|
||||||
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
|
statusDesc = a.optString("statusDesc"),
|
||||||
|
profileImageHash = profile.customerImage,
|
||||||
|
loginTag = "mib_$username",
|
||||||
|
profileId = profile.profileId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return allAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
|
||||||
|
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
@@ -271,8 +315,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
val a = accountBalances.getJSONObject(i)
|
val a = accountBalances.getJSONObject(i)
|
||||||
allAccounts.add(
|
allAccounts.add(
|
||||||
MibAccount(
|
MibAccount(
|
||||||
|
bank = "MIB",
|
||||||
profileName = profile.name,
|
profileName = profile.name,
|
||||||
profileType = profile.profileType,
|
profileType = profile.profileType,
|
||||||
|
cifType = profile.cifType,
|
||||||
accountNumber = a.optString("accountNumber"),
|
accountNumber = a.optString("accountNumber"),
|
||||||
accountBriefName = a.optString("accountBriefName"),
|
accountBriefName = a.optString("accountBriefName"),
|
||||||
currencyName = a.optString("currencyName"),
|
currencyName = a.optString("currencyName"),
|
||||||
@@ -310,44 +356,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MibPersonalProfile(
|
|
||||||
val fullName: String,
|
|
||||||
val username: String,
|
|
||||||
val email: String,
|
|
||||||
val mobile: String,
|
|
||||||
val enrolled: String
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Fetches the customer's profile info from the Faisanet personal profile page. */
|
|
||||||
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
|
||||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
|
||||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
|
||||||
.get()
|
|
||||||
.header("Cookie", cookieHeader)
|
|
||||||
.build()
|
|
||||||
return try {
|
|
||||||
val resp = client.newCall(request).execute()
|
|
||||||
val html = resp.body?.string() ?: return null
|
|
||||||
resp.close()
|
|
||||||
fun scrape(label: String): String {
|
|
||||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
|
||||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
|
||||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
|
||||||
}
|
|
||||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
|
||||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
|
||||||
MibPersonalProfile(
|
|
||||||
fullName = fullName,
|
|
||||||
username = scrape("Username:"),
|
|
||||||
email = scrape("Email:"),
|
|
||||||
mobile = scrape("Mobile no:"),
|
|
||||||
enrolled = scrape("Enrolled:")
|
|
||||||
)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
|
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
|
||||||
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
|
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
|
||||||
val payload = baseData(session, "P41").apply {
|
val payload = baseData(session, "P41").apply {
|
||||||
@@ -389,11 +397,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||||
|
|
||||||
private fun getOrCreateAppId(): String {
|
private fun getOrCreateAppId(): String {
|
||||||
var id = credentialStore.loadMibAppId()
|
var id = credentialStore.loadMibAppId(loginId)
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||||
credentialStore.saveMibAppId(id)
|
credentialStore.saveMibAppId(loginId, id)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
|
|
||||||
|
// Kept for source compatibility within the mib package
|
||||||
|
typealias MibAccount = BankAccount
|
||||||
|
typealias MibBeneficiary = BankContact
|
||||||
|
typealias Transaction = BankTransaction
|
||||||
|
typealias MibBeneficiaryCategory = BankContactCategory
|
||||||
|
|
||||||
data class MibSession(
|
data class MibSession(
|
||||||
val appId: String,
|
val appId: String,
|
||||||
val xxid: String,
|
val xxid: String,
|
||||||
@@ -19,23 +30,6 @@ data class MibProfile(
|
|||||||
val customerImage: String?
|
val customerImage: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibAccount(
|
|
||||||
val profileName: String,
|
|
||||||
val profileType: String,
|
|
||||||
val accountNumber: String,
|
|
||||||
val accountBriefName: String,
|
|
||||||
val currencyName: String,
|
|
||||||
val accountTypeName: String,
|
|
||||||
val availableBalance: String,
|
|
||||||
val currentBalance: String,
|
|
||||||
val blockedAmount: String,
|
|
||||||
val mvrBalance: String,
|
|
||||||
val statusDesc: String,
|
|
||||||
val profileImageHash: String?,
|
|
||||||
val loginTag: String = "",
|
|
||||||
val profileId: String = "", // MIB profile ID; empty for BML accounts
|
|
||||||
val internalId: String = "" // BML internal UUID; empty for MIB accounts
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MibTransferResult(
|
data class MibTransferResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
@@ -44,27 +38,6 @@ data class MibTransferResult(
|
|||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibBeneficiaryCategory(
|
|
||||||
val id: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val numBenef: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MibBeneficiary(
|
|
||||||
val benefNo: String,
|
|
||||||
val benefName: String,
|
|
||||||
val benefNickName: String,
|
|
||||||
val benefAccount: String,
|
|
||||||
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
|
|
||||||
val bankColor: String,
|
|
||||||
val benefBankName: String,
|
|
||||||
val bankCode: String,
|
|
||||||
val benefStatus: String,
|
|
||||||
val transferCyDesc: String,
|
|
||||||
val customerImgHash: String?,
|
|
||||||
val benefCategoryId: String, // "0" = uncategorized
|
|
||||||
val profileId: String = "" // MIB profile ID; empty for BML contacts
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MibIpsAccountInfo(
|
data class MibIpsAccountInfo(
|
||||||
val accountName: String,
|
val accountName: String,
|
||||||
@@ -72,18 +45,17 @@ data class MibIpsAccountInfo(
|
|||||||
val bankId: String
|
val bankId: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Transaction(
|
|
||||||
val id: String,
|
data class MibCard(
|
||||||
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
|
val cardId: String,
|
||||||
val description: String,
|
val maskedCardNumber: String,
|
||||||
val amount: Double, // negative = debit, positive = credit
|
val cardStatus: String,
|
||||||
val currency: String,
|
val cardType: String,
|
||||||
val counterpartyName: String?,
|
val cardTypeDesc: String,
|
||||||
val reference: String?,
|
val customerId: String,
|
||||||
val accountNumber: String,
|
val phoneNumber: String,
|
||||||
val accountDisplayName: String,
|
val cardHolderName: String,
|
||||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
val loginTag: String
|
||||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibFinanceDeal(
|
data class MibFinanceDeal(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
data class MibPersonalProfile(
|
||||||
|
val fullName: String,
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val mobile: String,
|
||||||
|
val enrolled: String
|
||||||
|
)
|
||||||
|
|
||||||
|
class MibProfileClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||||
|
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||||
|
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||||
|
.get()
|
||||||
|
.header("Cookie", cookieHeader)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
val resp = client.newCall(request).execute()
|
||||||
|
val html = resp.body?.string() ?: return null
|
||||||
|
resp.close()
|
||||||
|
fun scrape(label: String): String {
|
||||||
|
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||||
|
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||||
|
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||||
|
}
|
||||||
|
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||||
|
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||||
|
MibPersonalProfile(
|
||||||
|
fullName = fullName,
|
||||||
|
username = scrape("Username:"),
|
||||||
|
email = scrape("Email:"),
|
||||||
|
mobile = scrape("Mobile no:"),
|
||||||
|
enrolled = scrape("Enrolled:")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,6 +68,7 @@ class MibTransferClient {
|
|||||||
.withWvHeaders(session)
|
.withWvHeaders(session)
|
||||||
.build()
|
.build()
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 419) throw SessionExpiredException()
|
||||||
val bodyStr = response.body?.string() ?: ""
|
val bodyStr = response.body?.string() ?: ""
|
||||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
||||||
if (json == null || !json.optBoolean("success")) {
|
if (json == null || !json.optBoolean("success")) {
|
||||||
|
|||||||
72
app/src/main/java/sh/sar/basedbank/api/models/BankModels.kt
Normal file
72
app/src/main/java/sh/sar/basedbank/api/models/BankModels.kt
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package sh.sar.basedbank.api.models
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
|
||||||
|
* The [bank] field identifies which bank owns this account.
|
||||||
|
*/
|
||||||
|
data class BankAccount(
|
||||||
|
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||||
|
val profileName: String,
|
||||||
|
val profileType: String,
|
||||||
|
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
|
||||||
|
val accountNumber: String,
|
||||||
|
val accountBriefName: String,
|
||||||
|
val currencyName: String,
|
||||||
|
val accountTypeName: String,
|
||||||
|
val availableBalance: String,
|
||||||
|
val currentBalance: String,
|
||||||
|
val blockedAmount: String,
|
||||||
|
val mvrBalance: String,
|
||||||
|
val statusDesc: String,
|
||||||
|
val profileImageHash: String?,
|
||||||
|
val loginTag: String = "",
|
||||||
|
val profileId: String = "", // profile ID used by the bank; empty if not applicable
|
||||||
|
val internalId: String = "" // bank-internal UUID or ID; empty if not applicable
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified contact/beneficiary model used across all banks.
|
||||||
|
* Each bank may interpret fields differently; see per-bank notes below.
|
||||||
|
*/
|
||||||
|
data class BankContact(
|
||||||
|
val benefNo: String,
|
||||||
|
val benefName: String,
|
||||||
|
val benefNickName: String,
|
||||||
|
val benefAccount: String,
|
||||||
|
val benefType: String, // MIB: L=Local, I=Internal, S=Swift; BML: "I"; Fahipay: "FAHIPAY"
|
||||||
|
val bankColor: String,
|
||||||
|
val benefBankName: String,
|
||||||
|
val bankCode: String,
|
||||||
|
val benefStatus: String,
|
||||||
|
val transferCyDesc: String,
|
||||||
|
val customerImgHash: String?,
|
||||||
|
val benefCategoryId: String, // MIB: numeric category ID or "0"; BML: "BML"; Fahipay: "FAHIPAY"
|
||||||
|
val profileId: String = "" // owning profile ID; empty where not applicable
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact category (group) used across MIB and Fahipay.
|
||||||
|
*/
|
||||||
|
data class BankContactCategory(
|
||||||
|
val id: String,
|
||||||
|
val categoryName: String,
|
||||||
|
val numBenef: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified transaction model used across all banks.
|
||||||
|
* [source] identifies the originating bank/account type.
|
||||||
|
*/
|
||||||
|
data class BankTransaction(
|
||||||
|
val id: String,
|
||||||
|
val date: String, // "YYYY-MM-DD HH:mm:ss" (MIB/BML normalised) or ISO8601
|
||||||
|
val description: String,
|
||||||
|
val amount: Double, // negative = debit, positive = credit
|
||||||
|
val currency: String,
|
||||||
|
val counterpartyName: String?,
|
||||||
|
val reference: String?,
|
||||||
|
val accountNumber: String,
|
||||||
|
val accountDisplayName: String,
|
||||||
|
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||||
|
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||||
|
)
|
||||||
@@ -9,8 +9,9 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
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.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||||
@@ -20,12 +21,13 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AccountHistoryAdapter(
|
class AccountHistoryAdapter(
|
||||||
private val account: MibAccount
|
private val account: BankAccount,
|
||||||
|
private val display: AccountHistoryDisplay
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class DateHeader(val label: String) : Item()
|
data class DateHeader(val label: String) : Item()
|
||||||
data class Trx(val transaction: Transaction) : Item()
|
data class Trx(val transaction: BankTransaction) : Item()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
@@ -34,7 +36,18 @@ class AccountHistoryAdapter(
|
|||||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyItemChanged(0) // refresh header card
|
||||||
|
// refresh all transaction rows
|
||||||
|
for (i in displayItems.indices) {
|
||||||
|
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
@@ -71,7 +84,7 @@ 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<BankTransaction>) {
|
||||||
_showLoadingFooter = false
|
_showLoadingFooter = false
|
||||||
displayItems.clear()
|
displayItems.clear()
|
||||||
lastInsertedDateKey = ""
|
lastInsertedDateKey = ""
|
||||||
@@ -92,7 +105,7 @@ class AccountHistoryAdapter(
|
|||||||
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
||||||
* notifications, so the RecyclerView doesn't reset scroll position.
|
* notifications, so the RecyclerView doesn't reset scroll position.
|
||||||
*/
|
*/
|
||||||
fun appendTransactions(newTransactions: List<Transaction>) {
|
fun appendTransactions(newTransactions: List<BankTransaction>) {
|
||||||
if (newTransactions.isEmpty()) return
|
if (newTransactions.isEmpty()) return
|
||||||
if (_showLoadingFooter) {
|
if (_showLoadingFooter) {
|
||||||
val pos = itemCount - 1
|
val pos = itemCount - 1
|
||||||
@@ -138,7 +151,7 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is HeaderVH -> holder.bind(account)
|
is HeaderVH -> holder.bind(display)
|
||||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
||||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
||||||
else -> Unit
|
else -> Unit
|
||||||
@@ -147,37 +160,20 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(acc: MibAccount) {
|
fun bind(d: AccountHistoryDisplay) {
|
||||||
b.tvHeaderAccountName.text = acc.accountBriefName
|
b.tvHeaderAccountName.text = d.name
|
||||||
b.tvHeaderAccountNumber.text = acc.accountNumber
|
b.tvHeaderAccountNumber.text = d.number
|
||||||
b.tvHeaderPillBank.text = when {
|
b.tvHeaderPillBank.text = d.bankPill
|
||||||
acc.profileType.startsWith("BML") -> "BML"
|
b.tvHeaderPillType.text = d.typeLabel
|
||||||
acc.profileType == "FAHIPAY" -> "FP"
|
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
|
||||||
else -> null
|
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
|
||||||
}
|
if (d.blockedBalance != null) {
|
||||||
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
|
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
|
||||||
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
|
|
||||||
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
|
|
||||||
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
|
|
||||||
if (blocked > 0.0) {
|
|
||||||
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
|
|
||||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
b.llHeaderBlocked.visibility = View.GONE
|
b.llHeaderBlocked.visibility = View.GONE
|
||||||
}
|
}
|
||||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
|
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
}
|
|
||||||
|
|
||||||
private fun friendlyType(raw: String): String {
|
|
||||||
val u = raw.trim().uppercase()
|
|
||||||
return when {
|
|
||||||
u.contains("SAVING") -> "Savings"
|
|
||||||
u.contains("CURRENT") -> "Current"
|
|
||||||
u.contains("WADIAH") -> "Islamic"
|
|
||||||
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
|
|
||||||
u.contains("PREPAID") -> "Prepaid"
|
|
||||||
else -> raw.trim().take(12)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +184,7 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(trx: Transaction) {
|
fun bind(trx: BankTransaction) {
|
||||||
val isCredit = trx.amount >= 0
|
val isCredit = trx.amount >= 0
|
||||||
val color = sourceColor(trx.source)
|
val color = sourceColor(trx.source)
|
||||||
val name = trx.counterpartyName ?: trx.description
|
val name = trx.counterpartyName ?: trx.description
|
||||||
@@ -226,17 +222,22 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
b.tvDate.text = formatTime(trx.date)
|
b.tvDate.text = formatTime(trx.date)
|
||||||
|
|
||||||
val sign = if (isCredit) "+" else "-"
|
if (hideAmounts) {
|
||||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||||
b.tvAmount.setTextColor(
|
} else {
|
||||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
val sign = if (isCredit) "+" else "-"
|
||||||
)
|
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||||
|
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||||
|
b.tvAmount.setTextColor(
|
||||||
|
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
b.root.setOnClickListener { showDetail(trx) }
|
b.root.setOnClickListener { showDetail(trx) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDetail(trx: Transaction) {
|
private fun showDetail(trx: BankTransaction) {
|
||||||
val ctx = b.root.context
|
val ctx = b.root.context
|
||||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||||
val details = buildString {
|
val details = buildString {
|
||||||
@@ -297,6 +298,11 @@ class AccountHistoryAdapter(
|
|||||||
return FULL_DATE_FMT.format(date)
|
return FULL_DATE_FMT.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
fun sourceColor(source: String) = when (source) {
|
fun sourceColor(source: String) = when (source) {
|
||||||
"MIB" -> "#FE860E"
|
"MIB" -> "#FE860E"
|
||||||
"BML", "BML_CARD" -> "#0066A1"
|
"BML", "BML_CARD" -> "#0066A1"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.util.Base64
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -17,23 +19,18 @@ 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.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
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.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
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.AccountHistoryParser
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.HistoryFetcher
|
||||||
import sh.sar.basedbank.util.MerchantIconCache
|
import sh.sar.basedbank.util.MerchantIconCache
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class AccountHistoryFragment : Fragment() {
|
class AccountHistoryFragment : Fragment() {
|
||||||
|
|
||||||
@@ -42,29 +39,21 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private lateinit var adapter: AccountHistoryAdapter
|
private lateinit var adapter: AccountHistoryAdapter
|
||||||
private lateinit var account: MibAccount
|
private lateinit var account: BankAccount
|
||||||
|
private lateinit var fetcher: HistoryFetcher
|
||||||
|
|
||||||
private val allTransactions = mutableListOf<Transaction>()
|
private val allTransactions = mutableListOf<BankTransaction>()
|
||||||
private var searchQuery = ""
|
private var searchQuery = ""
|
||||||
private var firstPageDone = false
|
private var firstPageDone = false
|
||||||
private val pendingImageNames = mutableSetOf<String>()
|
private val pendingImageNames = mutableSetOf<String>()
|
||||||
private val pendingIconUrls = mutableSetOf<String>()
|
private val pendingIconUrls = mutableSetOf<String>()
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
private var mibNextStart = 1
|
|
||||||
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
|
|
||||||
private var bmlNextPage = 1
|
|
||||||
private var bmlTotalPages = -1
|
|
||||||
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
|
|
||||||
private var fahipayNextStart = 0
|
|
||||||
private var fahipayTotal = -1
|
|
||||||
private var isLoading = false
|
private var isLoading = false
|
||||||
private val pageSize = 10
|
private val pageSize = 10
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||||
|
|
||||||
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
|
fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
||||||
}
|
}
|
||||||
@@ -79,15 +68,29 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
||||||
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
||||||
|
fetcher = HistoryFetcher(account)
|
||||||
|
|
||||||
adapter = AccountHistoryAdapter(account)
|
val historyDisplay = AccountHistoryParser.from(account) ?: return
|
||||||
|
adapter = AccountHistoryAdapter(account, historyDisplay)
|
||||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||||
adapter.onTransferClick = { acc ->
|
adapter.onTransferClick = { acc ->
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||||
}
|
}
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||||
if (dy <= 0 || isLoading) return
|
if (dy <= 0 || isLoading) return
|
||||||
@@ -104,11 +107,10 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
override fun afterTextChanged(s: Editable?) {
|
override fun afterTextChanged(s: Editable?) {
|
||||||
searchQuery = s?.toString()?.trim() ?: ""
|
searchQuery = s?.toString()?.trim() ?: ""
|
||||||
filterAndDisplay()
|
filterAndDisplay()
|
||||||
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
|
if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load cache immediately, then fetch fresh data in background
|
|
||||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
allTransactions.addAll(cached)
|
allTransactions.addAll(cached)
|
||||||
@@ -116,6 +118,14 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
if (isLoading) {
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
} else {
|
||||||
|
resetAndReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -133,19 +143,19 @@ class AccountHistoryFragment : 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 isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
|
private fun resetAndReload() {
|
||||||
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
allTransactions.clear()
|
||||||
private fun isFahipay() = account.profileType == "FAHIPAY"
|
pendingImageNames.clear()
|
||||||
|
pendingIconUrls.clear()
|
||||||
private fun hasMore(): Boolean = when {
|
firstPageDone = false
|
||||||
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
fetcher = HistoryFetcher(account)
|
||||||
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
adapter.setTransactions(emptyList())
|
||||||
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
|
binding.emptyView.visibility = View.GONE
|
||||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
loadNextPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
if (isLoading || !hasMore()) return
|
if (isLoading || !fetcher.hasMore()) return
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
if (firstPageDone && allTransactions.isNotEmpty()) {
|
if (firstPageDone && allTransactions.isNotEmpty()) {
|
||||||
@@ -155,74 +165,14 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
|
val transactions = fetcher.fetchNextPage(app, pageSize)
|
||||||
when {
|
|
||||||
isFahipay() -> {
|
|
||||||
val session = app.fahipaySession ?: return@withContext emptyList()
|
|
||||||
val flow = FahipayLoginFlow()
|
|
||||||
flow.setSessionCookie(session.sessionCookie)
|
|
||||||
val (list, total) = flow.fetchHistory(
|
|
||||||
session = session,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
accountNumber = account.accountNumber,
|
|
||||||
start = fahipayNextStart
|
|
||||||
)
|
|
||||||
if (total > 0) fahipayTotal = total
|
|
||||||
fahipayNextStart += list.size
|
|
||||||
list
|
|
||||||
}
|
|
||||||
isMib() -> {
|
|
||||||
val session = app.mibSession ?: return@withContext emptyList()
|
|
||||||
app.mibMutex.withLock {
|
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
|
||||||
session = session,
|
|
||||||
accountNo = account.accountNumber,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
start = mibNextStart,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
if (total > 0) mibTotalCount = total
|
|
||||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isBmlCard() -> {
|
|
||||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
|
||||||
val cal = Calendar.getInstance()
|
|
||||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
|
||||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
|
||||||
cardMonthOffset++
|
|
||||||
BmlLoginFlow().fetchCardHistory(
|
|
||||||
session = session,
|
|
||||||
cardId = account.internalId,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
accountNumber = account.accountNumber,
|
|
||||||
month = month
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
|
||||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
|
||||||
session = session,
|
|
||||||
accountId = account.internalId,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
accountNumber = account.accountNumber,
|
|
||||||
page = bmlNextPage
|
|
||||||
)
|
|
||||||
if (totalPages > 0) bmlTotalPages = totalPages
|
|
||||||
bmlNextPage++
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|
||||||
if (!firstPageDone) {
|
if (!firstPageDone) {
|
||||||
firstPageDone = true
|
firstPageDone = true
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactions.isNotEmpty()) {
|
if (transactions.isNotEmpty()) {
|
||||||
@@ -233,7 +183,6 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
||||||
if (searchQuery.isBlank()) {
|
if (searchQuery.isBlank()) {
|
||||||
// Append incrementally to preserve scroll position
|
|
||||||
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
adapter.appendTransactions(sorted)
|
adapter.appendTransactions(sorted)
|
||||||
binding.emptyView.visibility = View.GONE
|
binding.emptyView.visibility = View.GONE
|
||||||
@@ -243,7 +192,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
} else {
|
} else {
|
||||||
adapter.showLoadingFooter = false
|
adapter.showLoadingFooter = false
|
||||||
}
|
}
|
||||||
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
|
if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
|
||||||
} else {
|
} else {
|
||||||
adapter.showLoadingFooter = false
|
adapter.showLoadingFooter = false
|
||||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||||
@@ -261,7 +210,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.mibSession ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
@@ -288,8 +237,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||||
val bytes = response.body?.bytes() ?: return@launch
|
val bytes = response.body?.bytes() ?: return@launch
|
||||||
response.close()
|
response.close()
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
?: return@launch
|
|
||||||
MerchantIconCache.save(requireContext(), url, bitmap)
|
MerchantIconCache.save(requireContext(), url, bitmap)
|
||||||
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -3,70 +3,79 @@ package sh.sar.basedbank.ui.home
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
|
||||||
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 android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
|
||||||
class AccountsAdapter(
|
class AccountsAdapter(
|
||||||
accounts: List<MibAccount>,
|
accounts: List<BankAccount>,
|
||||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
private val onAccountClick: (BankAccount) -> Unit = {}
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class SectionTitle(val label: String) : Item()
|
data class SectionTitle(val label: String) : Item()
|
||||||
data class Account(val account: MibAccount) : Item()
|
data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||||
data class Card(val account: MibAccount) : Item()
|
data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
||||||
|
|
||||||
fun updateAccounts(accounts: List<MibAccount>) {
|
fun updateAccounts(accounts: List<BankAccount>) {
|
||||||
items.clear()
|
items.clear()
|
||||||
items.addAll(buildItems(accounts))
|
items.addAll(buildItems(accounts))
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
if (hideAmounts == hide) return
|
||||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
// Group non-prepaid accounts by their derived section title, preserving order
|
private fun buildItems(accounts: List<BankAccount>): List<Item> = buildList {
|
||||||
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
|
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||||
for (acc in nonPrepaid) {
|
val nonCards = displayed.filter { !it.second.isCard }
|
||||||
|
val cards = displayed.filter { it.second.isCard }
|
||||||
|
|
||||||
|
val groups = LinkedHashMap<String, MutableList<Pair<BankAccount, AccountListDisplay>>>()
|
||||||
|
for ((acc, display) in nonCards) {
|
||||||
val title = sectionTitle(acc)
|
val title = sectionTitle(acc)
|
||||||
groups.getOrPut(title) { mutableListOf() }.add(acc)
|
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
|
||||||
}
|
}
|
||||||
for ((title, group) in groups) {
|
for ((title, group) in groups) {
|
||||||
add(Item.SectionTitle(title))
|
add(Item.SectionTitle(title))
|
||||||
group.forEach { add(Item.Account(it)) }
|
group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prepaid.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
||||||
prepaid.forEach { add(Item.Card(it)) }
|
cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sectionTitle(account: MibAccount): String {
|
private fun sectionTitle(account: BankAccount): String {
|
||||||
val profileLabel = when (account.profileType) {
|
val bankName = when (account.bank) {
|
||||||
"0" -> "Personal"
|
"BML" -> "Bank of Maldives"
|
||||||
"1" -> "Business"
|
"FAHIPAY" -> "Fahipay"
|
||||||
else -> account.profileName
|
"MIB" -> "Maldives Islamic Bank"
|
||||||
|
else -> account.bank
|
||||||
}
|
}
|
||||||
val bank = when {
|
val profileLabel = when (account.bank) {
|
||||||
account.profileType.startsWith("BML") -> "Bank of Maldives"
|
"MIB" -> account.cifType.ifBlank { account.profileName }
|
||||||
account.profileType == "FAHIPAY" -> "Fahipay"
|
else -> account.profileName
|
||||||
else -> "Maldives Islamic Bank"
|
|
||||||
}
|
}
|
||||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
|
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
@@ -78,17 +87,17 @@ class AccountsAdapter(
|
|||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
val inflater = LayoutInflater.from(parent.context)
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
return when (viewType) {
|
return when (viewType) {
|
||||||
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||||
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
||||||
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (val item = items[position]) {
|
when (val item = items[position]) {
|
||||||
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
||||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
|
is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
|
||||||
is Item.Card -> (holder as CardViewHolder).bind(item.account)
|
is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,14 +112,15 @@ class AccountsAdapter(
|
|||||||
|
|
||||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(account: MibAccount) {
|
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||||
binding.tvAccountName.text = account.accountBriefName
|
binding.tvAccountName.text = display.name
|
||||||
binding.tvAccountNumber.text = account.accountNumber
|
binding.tvAccountNumber.text = display.number
|
||||||
binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
|
binding.tvAccountType.text = display.typeLabel
|
||||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
copyToClipboard(it.context, account.accountNumber)
|
copyToClipboard(it.context, display.number)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,28 +128,22 @@ class AccountsAdapter(
|
|||||||
|
|
||||||
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(account: MibAccount) {
|
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||||
val brand = cardBrand(account.accountTypeName)
|
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
|
||||||
binding.tvCardBrand.text = brand.label
|
binding.tvCardName.text = display.name
|
||||||
binding.tvCardBrand.background = GradientDrawable().apply {
|
binding.tvCardNumber.text = display.number
|
||||||
shape = GradientDrawable.RECTANGLE
|
binding.tvCardProduct.text = display.typeLabel
|
||||||
cornerRadius = 100f
|
|
||||||
setColor(Color.parseColor(brand.color))
|
|
||||||
}
|
|
||||||
binding.tvCardName.text = account.accountBriefName
|
|
||||||
binding.tvCardNumber.text = account.accountNumber
|
|
||||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||||
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
|
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
|
if (display.statusLabel != null) {
|
||||||
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
binding.tvCardStatus.text = display.statusLabel
|
||||||
if (isActive) {
|
|
||||||
binding.tvCardStatus.visibility = View.GONE
|
|
||||||
binding.root.alpha = 1f
|
|
||||||
} else {
|
|
||||||
binding.tvCardStatus.text = account.statusDesc
|
|
||||||
binding.tvCardStatus.visibility = View.VISIBLE
|
binding.tvCardStatus.visibility = View.VISIBLE
|
||||||
binding.root.alpha = 0.45f
|
binding.root.alpha = 0.45f
|
||||||
|
} else {
|
||||||
|
binding.tvCardStatus.visibility = View.GONE
|
||||||
|
binding.root.alpha = 1f
|
||||||
}
|
}
|
||||||
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,35 +153,15 @@ class AccountsAdapter(
|
|||||||
private const val TYPE_ACCOUNT = 1
|
private const val TYPE_ACCOUNT = 1
|
||||||
private const val TYPE_CARD = 2
|
private const val TYPE_CARD = 2
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
private fun copyToClipboard(context: Context, accountNumber: String) {
|
private fun copyToClipboard(context: Context, accountNumber: String) {
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
||||||
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun friendlyAccountType(raw: String): String {
|
|
||||||
val u = raw.trim().uppercase()
|
|
||||||
return when {
|
|
||||||
u == "SAVINGS ACCOUNT" ||
|
|
||||||
u == "SAVING ACCOUNT" -> "Savings"
|
|
||||||
u == "CURRENT ACCOUNT" ||
|
|
||||||
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
|
||||||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
|
||||||
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
|
||||||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
|
||||||
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
|
||||||
else -> raw.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Brand(val label: String, val color: String)
|
|
||||||
|
|
||||||
private fun cardBrand(productName: String): Brand = when {
|
|
||||||
productName.contains("AMEX", ignoreCase = true) ||
|
|
||||||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> Brand("AMEX", "#016FD0")
|
|
||||||
productName.contains("VISA", ignoreCase = true) -> Brand("VISA", "#1A1F71")
|
|
||||||
productName.contains("MASTERCARD", ignoreCase = true) -> Brand("MC", "#FF5F00")
|
|
||||||
else -> Brand("CARD", "#555555")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
|||||||
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.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -26,10 +28,28 @@ class AccountsFragment : Fragment() {
|
|||||||
adapter = AccountsAdapter(emptyList()) { account ->
|
adapter = AccountsAdapter(emptyList()) { account ->
|
||||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||||
}
|
}
|
||||||
|
adapter.onTransferClick = { account ->
|
||||||
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||||
|
}
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ActivitiesAdapter(
|
||||||
|
private val onItemClick: (ReceiptStore.Entry) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class DateHeader(val label: String) : Item()
|
||||||
|
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val displayItems = mutableListOf<Item>()
|
||||||
|
|
||||||
|
fun setEntries(entries: List<ReceiptStore.Entry>) {
|
||||||
|
displayItems.clear()
|
||||||
|
var lastDateKey = ""
|
||||||
|
for (entry in entries) {
|
||||||
|
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
|
||||||
|
if (dateKey != lastDateKey) {
|
||||||
|
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
|
||||||
|
lastDateKey = dateKey
|
||||||
|
}
|
||||||
|
displayItems.add(Item.ReceiptItem(entry))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = displayItems.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) =
|
||||||
|
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE_HEADER)
|
||||||
|
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||||
|
else
|
||||||
|
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (holder) {
|
||||||
|
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||||
|
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ReceiptVH(private val b: ItemTransactionBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(entry: ReceiptStore.Entry) {
|
||||||
|
val d = entry.data
|
||||||
|
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
|
||||||
|
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
|
b.fvAvatar.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
|
||||||
|
}
|
||||||
|
b.tvInitial.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvInitial.text = initial
|
||||||
|
|
||||||
|
b.tvCounterparty.text = d.toLabel
|
||||||
|
b.tvCounterparty.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvDescription.text = buildString {
|
||||||
|
append(d.fromLabel)
|
||||||
|
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
|
||||||
|
}
|
||||||
|
b.tvDate.text = formatTime(entry.savedAt)
|
||||||
|
|
||||||
|
b.tvAmount.text = "- ${d.currency} ${d.amount}"
|
||||||
|
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
|
||||||
|
|
||||||
|
b.root.setOnClickListener { onItemClick(entry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDateHeader(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("HH:mm", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE_HEADER = 0
|
||||||
|
private const val TYPE_RECEIPT = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
|
||||||
|
class ActivitiesFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentActivitiesBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var adapter: ActivitiesAdapter
|
||||||
|
private val allEntries = mutableListOf<ReceiptStore.Entry>()
|
||||||
|
private var searchQuery = ""
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ActivitiesAdapter { entry ->
|
||||||
|
(activity as? HomeActivity)?.showWithBackStack(
|
||||||
|
TransferReceiptFragment.newInstance(entry.data, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext()
|
||||||
|
.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.etSearch.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
searchQuery = s?.toString()?.trim() ?: ""
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_activities)
|
||||||
|
// Reload in case a new receipt was added while we were away
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadEntries() {
|
||||||
|
allEntries.clear()
|
||||||
|
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterAndDisplay() {
|
||||||
|
val filtered = if (searchQuery.isBlank()) allEntries
|
||||||
|
else allEntries.filter { entry ->
|
||||||
|
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
adapter.setEntries(filtered)
|
||||||
|
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,20 +12,27 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Filter
|
||||||
|
import android.widget.Filterable
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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.BmlAccountValidation
|
import sh.sar.basedbank.api.bml.BmlAccountValidation
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||||
@@ -43,7 +50,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
private data class DestinationOption(
|
private data class DestinationOption(
|
||||||
val label: String,
|
val label: String,
|
||||||
val isBml: Boolean,
|
val isBml: Boolean,
|
||||||
val mibProfile: MibProfile? = null
|
val mibProfile: MibProfile? = null,
|
||||||
|
val mibLoginId: String? = null,
|
||||||
|
val bmlLoginId: String? = null,
|
||||||
|
val subtitle: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
private var destinations: List<DestinationOption> = emptyList()
|
private var destinations: List<DestinationOption> = emptyList()
|
||||||
@@ -55,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private var selectedImageBase64: String = ""
|
private var selectedImageBase64: String = ""
|
||||||
private var selectedCategoryId: String = "0"
|
private var selectedCategoryId: String = "0"
|
||||||
private var categories: List<MibBeneficiaryCategory> = emptyList()
|
private var categories: List<BankContactCategory> = emptyList()
|
||||||
|
|
||||||
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||||
uri ?: return@registerForActivityResult
|
uri ?: return@registerForActivityResult
|
||||||
@@ -83,21 +93,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun buildDestinations(): List<DestinationOption> {
|
private fun buildDestinations(): List<DestinationOption> {
|
||||||
val list = mutableListOf<DestinationOption>()
|
val list = mutableListOf<DestinationOption>()
|
||||||
for (profile in app.mibProfiles) {
|
for ((loginId, profiles) in app.mibProfilesMap) {
|
||||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
|
for (profile in profiles) {
|
||||||
|
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (app.anyBmlSession() != null) {
|
val store = CredentialStore(requireContext())
|
||||||
list.add(DestinationOption("BML · Personal", isBml = true))
|
for ((loginId, profiles) in app.bmlProfilesMap) {
|
||||||
|
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
for (profile in profiles) {
|
||||||
|
if (app.bmlSessions.containsKey(profile.profileId)) {
|
||||||
|
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupDestinationDropdown() {
|
private fun setupDestinationDropdown() {
|
||||||
val labels = destinations.map { it.label }
|
val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
|
||||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels)
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||||
|
getDropDownView(position, convertView, parent)
|
||||||
|
|
||||||
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, parent, false)
|
||||||
|
val opt = destinations[position]
|
||||||
|
view.findViewById<TextView>(android.R.id.text1).text = opt.label
|
||||||
|
val text2 = view.findViewById<TextView>(android.R.id.text2)
|
||||||
|
if (opt.subtitle.isNotBlank()) {
|
||||||
|
text2.text = opt.subtitle
|
||||||
|
text2.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
text2.visibility = View.GONE
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = destinations; count = destinations.size }
|
||||||
|
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||||
|
override fun convertResultToString(r: Any?) = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
binding.actvDestination.setAdapter(adapter)
|
binding.actvDestination.setAdapter(adapter)
|
||||||
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
||||||
selectedDest = destinations[position]
|
selectedDest = destinations[position]
|
||||||
|
binding.actvDestination.setText(destinations[position].label, false)
|
||||||
clearLookupResult()
|
clearLookupResult()
|
||||||
updateMibOnlyVisibility()
|
updateMibOnlyVisibility()
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
@@ -129,6 +170,22 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startLookupLoading() {
|
||||||
|
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||||
|
setStyle(CircularProgressDrawable.DEFAULT)
|
||||||
|
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
binding.tilAccount.endIconDrawable = spinner
|
||||||
|
binding.tilAccount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLookupLoading() {
|
||||||
|
binding.tilAccount.isEnabled = true
|
||||||
|
binding.tilAccount.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupAccountSearch() {
|
private fun setupAccountSearch() {
|
||||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||||
}
|
}
|
||||||
@@ -167,7 +224,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.tilAccount.isEnabled = false
|
startLookupLoading()
|
||||||
binding.tilDestination.isEnabled = false
|
binding.tilDestination.isEnabled = false
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
|
|
||||||
@@ -175,7 +232,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
||||||
}
|
}
|
||||||
binding.tilAccount.isEnabled = true
|
stopLookupLoading()
|
||||||
binding.tilDestination.isEnabled = true
|
binding.tilDestination.isEnabled = true
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
showLookupResult(result, input)
|
showLookupResult(result, input)
|
||||||
@@ -186,19 +243,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupForBml(input: String): BmlAccountValidation? {
|
private fun lookupForBml(input: String): BmlAccountValidation? {
|
||||||
val bmlSess = app.anyBmlSession() ?: return null
|
val loginId = selectedDest?.bmlLoginId ?: return null
|
||||||
val bmlFlow = BmlLoginFlow()
|
val bmlSess = app.bmlSessions[loginId] ?: return null
|
||||||
|
|
||||||
// 1) Try BML validate
|
// 1) Try BML validate
|
||||||
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
val validated = try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||||
if (validated != null) return validated
|
if (validated != null) return validated
|
||||||
|
|
||||||
// 2) Try BML MIB verify
|
// 2) Try BML MIB verify
|
||||||
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
val mibVerified = try { BmlValidateClient().verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||||
if (mibVerified != null) return mibVerified
|
if (mibVerified != null) return mibVerified
|
||||||
|
|
||||||
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
||||||
val mibSess = app.mibSession ?: return null
|
val mibSess = app.anyMibSession() ?: return null
|
||||||
return try {
|
return try {
|
||||||
val info = MibTransferClient().lookup(mibSess, input)
|
val info = MibTransferClient().lookup(mibSess, input)
|
||||||
BmlAccountValidation(
|
BmlAccountValidation(
|
||||||
@@ -215,11 +271,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
|
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
|
||||||
val mibSess = app.mibSession ?: return null
|
val loginId = dest.mibLoginId ?: return null
|
||||||
|
val mibSess = app.mibSessions[loginId] ?: return null
|
||||||
val profile = dest.mibProfile ?: return null
|
val profile = dest.mibProfile ?: return null
|
||||||
|
|
||||||
val mibResult = try {
|
val mibResult = try {
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||||
val info = MibTransferClient().lookup(mibSess, input)
|
val info = MibTransferClient().lookup(mibSess, input)
|
||||||
BmlAccountValidation(
|
BmlAccountValidation(
|
||||||
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
||||||
@@ -237,7 +294,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
||||||
val bmlSess = app.anyBmlSession() ?: return null
|
val bmlSess = app.anyBmlSession() ?: return null
|
||||||
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
return try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
|
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
|
||||||
@@ -341,6 +398,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
binding.tilAlias.error = null
|
binding.tilAlias.error = null
|
||||||
|
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
|
binding.btnSave.text = "Saving..."
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) {
|
||||||
@@ -352,31 +410,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
binding.btnSave.isEnabled = true
|
binding.btnSave.isEnabled = true
|
||||||
|
binding.btnSave.text = "Save"
|
||||||
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveToBml(alias: String): Boolean {
|
private fun saveToBml(alias: String): Boolean {
|
||||||
val bmlSess = app.anyBmlSession() ?: return false
|
val loginId = selectedDest?.bmlLoginId ?: return false
|
||||||
|
val bmlSess = app.bmlSessions[loginId] ?: return false
|
||||||
val lookup = bmlLookup ?: return false
|
val lookup = bmlLookup ?: return false
|
||||||
val bmlFlow = BmlLoginFlow()
|
|
||||||
val account = lookup.account
|
val account = lookup.account
|
||||||
return when {
|
return when {
|
||||||
account.matches(Regex("^7\\d{12}$")) ->
|
account.matches(Regex("^7\\d{12}$")) ->
|
||||||
// BML account → IAT
|
// BML account → IAT
|
||||||
bmlFlow.saveContact(bmlSess, "IAT", account, alias)
|
BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
|
||||||
account.matches(Regex("^9\\d{16}$")) ->
|
account.matches(Regex("^9\\d{16}$")) ->
|
||||||
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
|
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
|
||||||
bmlFlow.saveContact(bmlSess, "DOT", account, alias,
|
BmlContactsClient().saveContact(bmlSess, "DOT", account, alias,
|
||||||
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
|
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveToMib(alias: String): Boolean {
|
private fun saveToMib(alias: String): Boolean {
|
||||||
val mibSess = app.mibSession ?: return false
|
|
||||||
val dest = selectedDest ?: return false
|
val dest = selectedDest ?: return false
|
||||||
|
val loginId = dest.mibLoginId ?: return false
|
||||||
|
val mibSess = app.mibSessions[loginId] ?: return false
|
||||||
val profile = dest.mibProfile ?: return false
|
val profile = dest.mibProfile ?: return false
|
||||||
val account = mibLookupAccount ?: return false
|
val account = mibLookupAccount ?: return false
|
||||||
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
|
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
|
||||||
@@ -388,7 +448,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val name = bmlLookup?.name ?: ""
|
val name = bmlLookup?.name ?: ""
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||||
MibContactsClient().createContact(
|
MibContactsClient().createContact(
|
||||||
session = mibSess,
|
session = mibSess,
|
||||||
benefType = benefType,
|
benefType = benefType,
|
||||||
@@ -425,17 +485,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (dest.isBml) {
|
if (dest.isBml) {
|
||||||
val bmlSess = app.anyBmlSession() ?: return@launch
|
val loginId = dest.bmlLoginId ?: return@launch
|
||||||
val loginId = app.bmlSessions.entries.firstOrNull { it.value == bmlSess }?.key ?: ""
|
val bmlSess = app.bmlSessions[loginId] ?: return@launch
|
||||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
|
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
|
||||||
val existing = viewModel.contacts.value ?: emptyList()
|
val existing = viewModel.contacts.value ?: emptyList()
|
||||||
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
||||||
viewModel.contacts.postValue(merged)
|
viewModel.contacts.postValue(merged)
|
||||||
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
|
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
|
||||||
} else {
|
} else {
|
||||||
val profile = dest.mibProfile ?: return@launch
|
val profile = dest.mibProfile ?: return@launch
|
||||||
val mibSess = app.mibSession ?: return@launch
|
val mibLoginId = dest.mibLoginId ?: return@launch
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
|
||||||
|
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
|
||||||
val fresh = MibContactsClient().fetchContacts(mibSess)
|
val fresh = MibContactsClient().fetchContacts(mibSess)
|
||||||
.map { it.copy(profileId = profile.profileId) }
|
.map { it.copy(profileId = profile.profileId) }
|
||||||
val existing = viewModel.contacts.value ?: emptyList()
|
val existing = viewModel.contacts.value ?: emptyList()
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
|
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
|
||||||
|
|
||||||
|
class CardSettingsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentCardSettingsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val adapter = CardSettingsAdapter(emptyList(), requireContext())
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.mibCards.observe(viewLifecycleOwner) { cards ->
|
||||||
|
if (cards == null) return@observe
|
||||||
|
adapter.update(cards)
|
||||||
|
binding.loadingView.visibility = View.GONE
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewModel.mibCards.value == null) {
|
||||||
|
binding.loadingView.visibility = View.VISIBLE
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_card_settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class CardSettingsAdapter(
|
||||||
|
private var cards: List<MibCard>,
|
||||||
|
private val context: Context
|
||||||
|
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
|
||||||
|
|
||||||
|
fun update(newCards: List<MibCard>) {
|
||||||
|
cards = newCards
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
||||||
|
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||||
|
override fun getItemCount() = cards.size
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||||
|
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||||
|
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||||
|
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
|
||||||
|
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
|
||||||
|
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
|
||||||
|
private val btnBlock: View = view.findViewById(R.id.btnBlock)
|
||||||
|
|
||||||
|
fun bind(card: MibCard) {
|
||||||
|
tvCardOwner.text = card.cardHolderName
|
||||||
|
tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber)
|
||||||
|
tvCardType.text = card.cardTypeDesc
|
||||||
|
val assetPath = PayWithCardFragment.cardImageAsset(card)
|
||||||
|
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
||||||
|
else ivCardImage.setImageDrawable(null)
|
||||||
|
val wip = View.OnClickListener {
|
||||||
|
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
btnChangePin.setOnClickListener(wip)
|
||||||
|
btnFreeze.setOnClickListener(wip)
|
||||||
|
btnBlock.setOnClickListener(wip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
||||||
private val profileImageHashes = mutableSetOf<String>()
|
private val profileImageHashes = mutableSetOf<String>()
|
||||||
private val app get() = requireActivity().application as BasedBankApp
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
private val session get() = app.mibSession
|
private val session get() = app.anyMibSession()
|
||||||
|
|
||||||
private var fromAccountNumber: String = ""
|
private var fromAccountNumber: String = ""
|
||||||
private var mediator: TabLayoutMediator? = null
|
private var mediator: TabLayoutMediator? = null
|
||||||
@@ -291,7 +291,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = if (hash in profileImageHashes) {
|
val base64 = if (hash in profileImageHashes) {
|
||||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||||
} else {
|
} else {
|
||||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
|
|||||||
@@ -12,23 +12,23 @@ import android.view.ViewGroup
|
|||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
class ContactsAdapter(
|
class ContactsAdapter(
|
||||||
private val imageCache: MutableMap<String, Bitmap>,
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onImageNeeded: (hash: String) -> Unit,
|
private val onImageNeeded: (hash: String) -> Unit,
|
||||||
private val onDeleteClick: (MibBeneficiary) -> Unit,
|
private val onDeleteClick: (ContactDisplay) -> Unit,
|
||||||
private val onTransferClick: (MibBeneficiary) -> Unit
|
private val onTransferClick: (ContactDisplay) -> Unit
|
||||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
private var allContacts: List<ContactDisplay> = emptyList()
|
||||||
private var displayed: List<MibBeneficiary> = emptyList()
|
private var displayed: List<ContactDisplay> = emptyList()
|
||||||
|
|
||||||
private var activeCategoryId: String? = null
|
private var activeCategoryId: String? = null
|
||||||
private var searchQuery: String = ""
|
private var searchQuery: String = ""
|
||||||
|
|
||||||
fun updateContacts(contacts: List<MibBeneficiary>) {
|
fun updateContacts(contacts: List<ContactDisplay>) {
|
||||||
allContacts = contacts
|
allContacts = contacts
|
||||||
applyFilter()
|
applyFilter()
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ class ContactsAdapter(
|
|||||||
fun updateImage(hash: String, bitmap: Bitmap) {
|
fun updateImage(hash: String, bitmap: Bitmap) {
|
||||||
imageCache[hash] = bitmap
|
imageCache[hash] = bitmap
|
||||||
displayed.forEachIndexed { index, contact ->
|
displayed.forEachIndexed { index, contact ->
|
||||||
if (contact.customerImgHash == hash) notifyItemChanged(index)
|
if (contact.imageHash == hash) notifyItemChanged(index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +48,11 @@ class ContactsAdapter(
|
|||||||
|
|
||||||
private fun applyFilter() {
|
private fun applyFilter() {
|
||||||
displayed = allContacts.filter { contact ->
|
displayed = allContacts.filter { contact ->
|
||||||
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
|
val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
|
||||||
val matchesSearch = searchQuery.isBlank() ||
|
val matchesSearch = searchQuery.isBlank() ||
|
||||||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
|
contact.name.contains(searchQuery, ignoreCase = true) ||
|
||||||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
|
contact.realName.contains(searchQuery, ignoreCase = true) ||
|
||||||
contact.benefAccount.contains(searchQuery)
|
contact.accountNumber.contains(searchQuery)
|
||||||
matchesCategory && matchesSearch
|
matchesCategory && matchesSearch
|
||||||
}
|
}
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
@@ -76,7 +76,7 @@ class ContactsAdapter(
|
|||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
|
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
|
||||||
val account = displayed[pos].benefAccount
|
val account = displayed[pos].accountNumber
|
||||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||||
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
|
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
|
||||||
@@ -88,7 +88,7 @@ class ContactsAdapter(
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
val contact = displayed[position]
|
val contact = displayed[position]
|
||||||
val cachedImage = contact.customerImgHash?.let { hash ->
|
val cachedImage = contact.imageHash?.let { hash ->
|
||||||
imageCache[hash] ?: run { onImageNeeded(hash); null }
|
imageCache[hash] ?: run { onImageNeeded(hash); null }
|
||||||
}
|
}
|
||||||
holder.bind(contact, cachedImage)
|
holder.bind(contact, cachedImage)
|
||||||
@@ -99,21 +99,24 @@ class ContactsAdapter(
|
|||||||
inner class ViewHolder(val binding: ItemContactBinding) :
|
inner class ViewHolder(val binding: ItemContactBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
|
fun bind(contact: ContactDisplay, photo: Bitmap?) {
|
||||||
val isFahipay = contact.benefType == "FAHIPAY"
|
binding.tvContactName.text = contact.name
|
||||||
binding.tvContactName.text = contact.benefNickName
|
binding.tvContactAccount.text = contact.accountNumber
|
||||||
binding.tvContactAccount.text = contact.benefAccount
|
binding.tvRealName.text = contact.detail ?: ""
|
||||||
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}"
|
binding.tvRealName.visibility =
|
||||||
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
binding.btnTransferContact.visibility =
|
||||||
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
binding.btnEditContact.visibility =
|
||||||
|
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
binding.btnDeleteContact.visibility =
|
||||||
|
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
|
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
binding.ivContactPhoto.setImageBitmap(photo)
|
binding.ivContactPhoto.setImageBitmap(photo)
|
||||||
} else {
|
} else {
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
binding.ivContactPhoto.setImageBitmap(
|
||||||
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
|
makeInitialsBitmap(contact.name, contact.bankColor)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
@@ -21,13 +23,15 @@ import kotlinx.coroutines.launch
|
|||||||
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.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
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.ContactDisplay
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.ContactManager
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
class ContactsFragment : Fragment() {
|
class ContactsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -38,12 +42,13 @@ class ContactsFragment : Fragment() {
|
|||||||
private val pendingHashes = mutableSetOf<String>()
|
private val pendingHashes = mutableSetOf<String>()
|
||||||
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
||||||
private val app get() = requireActivity().application as BasedBankApp
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
private val session get() = app.mibSession
|
private val session get() = app.anyMibSession()
|
||||||
|
|
||||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
private var allContacts: List<ContactDisplay> = emptyList()
|
||||||
private var currentSearch: String = ""
|
private var currentSearch: String = ""
|
||||||
private var mediator: TabLayoutMediator? = null
|
private var mediator: TabLayoutMediator? = null
|
||||||
private lateinit var pagerAdapter: ContactsPagerAdapter
|
private lateinit var pagerAdapter: ContactsPagerAdapter
|
||||||
|
private var contactsRefreshing = false
|
||||||
|
|
||||||
private data class TabPage(val categoryId: String?, val label: String)
|
private data class TabPage(val categoryId: String?, val label: String)
|
||||||
|
|
||||||
@@ -53,9 +58,9 @@ class ContactsFragment : Fragment() {
|
|||||||
private val density get() = resources.displayMetrics.density
|
private val density get() = resources.displayMetrics.density
|
||||||
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
|
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
|
||||||
ContactsAdapter(
|
ContactsAdapter(
|
||||||
imageCache = sharedImageCache,
|
imageCache = sharedImageCache,
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||||
onTransferClick = { contact -> openTransfer(contact) }
|
onTransferClick = { contact -> openTransfer(contact) }
|
||||||
).also { a ->
|
).also { a ->
|
||||||
a.setFilter(page.categoryId, currentSearch)
|
a.setFilter(page.categoryId, currentSearch)
|
||||||
@@ -63,7 +68,7 @@ class ContactsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateContacts(contacts: List<MibBeneficiary>) =
|
fun updateContacts(contacts: List<ContactDisplay>) =
|
||||||
contactAdapters.forEach { it.updateContacts(contacts) }
|
contactAdapters.forEach { it.updateContacts(contacts) }
|
||||||
|
|
||||||
fun updateSearch(query: String) =
|
fun updateSearch(query: String) =
|
||||||
@@ -86,7 +91,7 @@ class ContactsFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
clipToPadding = false
|
clipToPadding = false
|
||||||
val p4 = (4 * density).toInt()
|
val p4 = (4 * density).toInt()
|
||||||
val p80 = (80 * density).toInt()
|
val p80 = (65 * density).toInt()
|
||||||
setPadding(0, p4, 0, p80)
|
setPadding(0, p4, 0, p80)
|
||||||
adapter = contactAdapters[viewType]
|
adapter = contactAdapters[viewType]
|
||||||
}
|
}
|
||||||
@@ -113,21 +118,41 @@ class ContactsFragment : Fragment() {
|
|||||||
pagerAdapter.updateSearch(currentSearch)
|
pagerAdapter.updateSearch(currentSearch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fabMarginBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.fabAddContact) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
val lp = v.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
|
||||||
|
lp.bottomMargin = fabMarginBase + extraBottom
|
||||||
|
v.layoutParams = lp
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.fabAddContact.setOnClickListener {
|
binding.fabAddContact.setOnClickListener {
|
||||||
AddContactSheetFragment().show(childFragmentManager, "add_contact")
|
AddContactSheetFragment().show(childFragmentManager, "add_contact")
|
||||||
}
|
}
|
||||||
|
|
||||||
(activity as? HomeActivity)?.loadAllContacts()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
contactsRefreshing = true
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||||
rebuildPager(cats)
|
rebuildPager(cats)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
allContacts = contacts
|
allContacts = ContactListParser.fromList(contacts)
|
||||||
pagerAdapter.updateContacts(contacts)
|
pagerAdapter.updateContacts(allContacts)
|
||||||
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
||||||
binding.loadingView.visibility = View.GONE
|
binding.loadingView.visibility = View.GONE
|
||||||
|
if (contactsRefreshing) {
|
||||||
|
contactsRefreshing = false
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +163,7 @@ class ContactsFragment : Fragment() {
|
|||||||
}.also { it.attach() }
|
}.also { it.attach() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun rebuildPager(cats: List<MibBeneficiaryCategory>) {
|
private fun rebuildPager(cats: List<BankContactCategory>) {
|
||||||
val pages = buildList {
|
val pages = buildList {
|
||||||
add(TabPage(null, getString(R.string.contacts_tab_all)))
|
add(TabPage(null, getString(R.string.contacts_tab_all)))
|
||||||
cats.forEach { add(TabPage(it.id, it.categoryName)) }
|
cats.forEach { add(TabPage(it.id, it.categoryName)) }
|
||||||
@@ -150,31 +175,29 @@ class ContactsFragment : Fragment() {
|
|||||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openTransfer(contact: MibBeneficiary) {
|
private fun openTransfer(contact: ContactDisplay) {
|
||||||
val fragment = TransferFragment.newInstance(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.benefAccount,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.benefNickName,
|
displayName = contact.name,
|
||||||
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
subtitle = contact.transferSubtitle,
|
||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
imageHash = contact.customerImgHash
|
imageHash = contact.imageHash
|
||||||
)
|
)
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: MibBeneficiary) {
|
private fun confirmDelete(contact: ContactDisplay) {
|
||||||
AlertDialog.Builder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(R.string.contact_delete_title)
|
.setTitle(R.string.contact_delete_title)
|
||||||
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName))
|
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
||||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteContact(contact: MibBeneficiary) {
|
private fun deleteContact(contact: ContactDisplay) {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
|
||||||
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
|
|
||||||
}
|
|
||||||
if (success) {
|
if (success) {
|
||||||
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
|
||||||
removeFromViewModel(contact)
|
removeFromViewModel(contact)
|
||||||
@@ -184,27 +207,10 @@ class ContactsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteBml(contact: MibBeneficiary): Boolean {
|
private fun removeFromViewModel(contact: ContactDisplay) {
|
||||||
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
|
||||||
val contactId = contact.benefNo.removePrefix("bml_")
|
|
||||||
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deleteMib(contact: MibBeneficiary): Boolean {
|
|
||||||
val sess = session ?: return false
|
|
||||||
return try {
|
|
||||||
if (contact.profileId.isNotBlank()) {
|
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
|
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
|
|
||||||
}
|
|
||||||
MibContactsClient().deleteContact(sess, contact.benefNo)
|
|
||||||
} catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeFromViewModel(contact: MibBeneficiary) {
|
|
||||||
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
|
|
||||||
viewModel.contacts.value = updated
|
viewModel.contacts.value = updated
|
||||||
if (contact.benefCategoryId == "BML") {
|
if (contact.network == TransferNetwork.BML) {
|
||||||
updated.filter { it.benefCategoryId == "BML" }
|
updated.filter { it.benefCategoryId == "BML" }
|
||||||
.groupBy { it.profileId }
|
.groupBy { it.profileId }
|
||||||
.forEach { (loginId, contacts) ->
|
.forEach { (loginId, contacts) ->
|
||||||
@@ -221,7 +227,6 @@ 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)
|
val cached = ContactImageCache.load(requireContext(), hash)
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
view?.post { pagerAdapter.updateImage(hash, cached) }
|
view?.post { pagerAdapter.updateImage(hash, cached) }
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.LinearSnapHelper
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import kotlin.math.abs
|
||||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||||
|
|
||||||
@@ -27,64 +38,240 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||||
|
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||||
|
updatePendingFinances()
|
||||||
|
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
binding.btnTransfer.setOnClickListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer)
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
binding.btnPayMvQr.setOnClickListener {
|
|
||||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
val cardAdapter = DashboardCardAdapter()
|
||||||
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
|
binding.rvCards.adapter = cardAdapter
|
||||||
|
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||||
|
|
||||||
|
viewModel.mibCards.observe(viewLifecycleOwner) { cards ->
|
||||||
|
if (cards.isNullOrEmpty()) return@observe
|
||||||
|
cardAdapter.update(cards)
|
||||||
|
binding.sectionCards.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.nav_dashboard)
|
requireActivity().title = getString(R.string.nav_dashboard)
|
||||||
|
refreshQuickActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateBalances(accounts: List<MibAccount>) {
|
private fun refreshQuickActions() {
|
||||||
val mvrTotal = accounts
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val ids = NavCustomization.getQuickActions(prefs)
|
||||||
|
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
||||||
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
||||||
|
if (def != null) {
|
||||||
|
btn.setText(def.titleRes)
|
||||||
|
btn.icon = ContextCompat.getDrawable(requireContext(), def.iconRes)
|
||||||
|
}
|
||||||
|
btn.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(ids[i]) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateBalances(accounts: List<BankAccount>) {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
|
||||||
|
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" }
|
||||||
|
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
|
||||||
|
|
||||||
|
if (hide) {
|
||||||
|
binding.tvMvrBalance.text = "MVR ••••••"
|
||||||
|
binding.tvUsdBalance.text = "USD ••••••"
|
||||||
|
if (creditAccounts.isNotEmpty()) {
|
||||||
|
binding.rowCreditCards.visibility = View.VISIBLE
|
||||||
|
val hasMvrCredit = creditAccounts.any { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||||
|
val hasUsdCredit = creditAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
|
binding.cardMvrCredit.visibility = if (hasMvrCredit) View.VISIBLE else View.GONE
|
||||||
|
binding.cardUsdCredit.visibility = if (hasUsdCredit) View.VISIBLE else View.GONE
|
||||||
|
binding.tvMvrCredit.text = "MVR ••••••"
|
||||||
|
binding.tvUsdCredit.text = "USD ••••••"
|
||||||
|
} else {
|
||||||
|
binding.rowCreditCards.visibility = View.GONE
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val mvrTotal = nonCreditAccounts
|
||||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
val usdTotal = accounts
|
val usdTotal = nonCreditAccounts
|
||||||
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
|
|
||||||
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
|
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
|
||||||
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
|
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
|
||||||
|
|
||||||
|
val mvrCredit = creditAccounts
|
||||||
|
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||||
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
|
val usdCredit = creditAccounts
|
||||||
|
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
|
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||||
|
|
||||||
|
if (creditAccounts.isNotEmpty()) {
|
||||||
|
binding.rowCreditCards.visibility = View.VISIBLE
|
||||||
|
binding.cardMvrCredit.visibility = if (mvrCredit > 0) View.VISIBLE else View.GONE
|
||||||
|
binding.cardUsdCredit.visibility = if (usdCredit > 0) View.VISIBLE else View.GONE
|
||||||
|
binding.tvMvrCredit.text = "MVR %,.2f".format(mvrCredit)
|
||||||
|
binding.tvUsdCredit.text = "USD %,.2f".format(usdCredit)
|
||||||
|
} else {
|
||||||
|
binding.rowCreditCards.visibility = View.GONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val expandedLimits = mutableSetOf<Int>()
|
||||||
|
|
||||||
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
binding.containerForeignLimits.removeAllViews()
|
binding.containerForeignLimits.removeAllViews()
|
||||||
|
var cardIndex = 0
|
||||||
for (entry in entries) {
|
for (entry in entries) {
|
||||||
for (limit in entry.limits) {
|
for (limit in entry.limits) {
|
||||||
|
val idx = cardIndex++
|
||||||
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
||||||
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
|
||||||
card.tvLimitType.text = limit.type
|
card.root.setOnClickListener {
|
||||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
|
||||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
updateForeignLimits(entries)
|
||||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
}
|
||||||
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
|
||||||
else
|
|
||||||
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
|
||||||
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
|
||||||
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
|
||||||
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
|
||||||
else
|
|
||||||
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
|
||||||
binding.containerForeignLimits.addView(card.root)
|
binding.containerForeignLimits.addView(card.root)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
private fun bindLimitCard(
|
||||||
val total = deals.sumOf { it.outstandingAmount }
|
card: ItemForeignLimitBinding,
|
||||||
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
|
userName: String,
|
||||||
|
limit: BmlForeignLimit,
|
||||||
|
hide: Boolean,
|
||||||
|
expanded: Boolean
|
||||||
|
) {
|
||||||
|
card.tvLimitUserName.text = userName.ifBlank { "BML" }
|
||||||
|
card.tvLimitType.text = limit.type
|
||||||
|
|
||||||
|
// ECOM (always visible)
|
||||||
|
card.tvLimitEcom.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||||
|
card.progressEcom.progress = if (hide || limit.ecomLimit <= 0) 0
|
||||||
|
else ((limit.ecomRemaining / limit.ecomLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// General (always visible)
|
||||||
|
card.tvLimitGeneral.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||||
|
card.progressGeneral.progress = if (hide || limit.generalCap <= 0) 0
|
||||||
|
else ((limit.generalRemaining / limit.generalCap) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// Expanded section
|
||||||
|
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||||
|
card.dividerLimitDetails.visibility = detailsVisible
|
||||||
|
card.detailsGroup.visibility = detailsVisible
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
// ATM
|
||||||
|
if (!limit.isAtmEnabled) card.tvAtmLabel.append(" (Disabled)")
|
||||||
|
card.tvLimitAtm.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||||
|
card.progressAtm.progress = if (hide || limit.atmLimit <= 0) 0
|
||||||
|
else ((limit.atmRemaining / limit.atmLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// POS
|
||||||
|
if (!limit.isPosEnabled) card.tvPosLabel.append(" (Disabled)")
|
||||||
|
card.tvLimitPos.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||||
|
card.progressPos.progress = if (hide || limit.posLimit <= 0) 0
|
||||||
|
else ((limit.posRemaining / limit.posLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
|
||||||
|
// Medical
|
||||||
|
card.tvLimitMedical.text = if (hide) "USD ••••••"
|
||||||
|
else "USD %,.2f / %,.0f".format(limit.medicalRemaining, limit.totalLimit)
|
||||||
|
card.progressMedical.progress = if (hide || limit.totalLimit <= 0) 0
|
||||||
|
else ((limit.medicalRemaining / limit.totalLimit) * 100).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePendingFinances() {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
||||||
|
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
|
||||||
|
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
|
||||||
|
val total = mibTotal + bmlTotal
|
||||||
|
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() {
|
||||||
|
private var cards: List<MibCard> = emptyList()
|
||||||
|
|
||||||
|
fun update(newCards: List<MibCard>) {
|
||||||
|
cards = newCards
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_card_dashboard, parent, false)
|
||||||
|
return VH(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||||
|
override fun getItemCount() = cards.size
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||||
|
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||||
|
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||||
|
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
|
||||||
|
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
|
||||||
|
|
||||||
|
fun bind(card: MibCard) {
|
||||||
|
tvCardOwner.text = card.cardHolderName
|
||||||
|
tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber)
|
||||||
|
val assetPath = PayWithCardFragment.cardImageAsset(card)
|
||||||
|
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
||||||
|
else ivCardImage.setImageDrawable(null)
|
||||||
|
btnPayQr.setOnClickListener {
|
||||||
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||||
|
val nfcSupported = nfcAdapter != null
|
||||||
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
|
if (nfcSupported) {
|
||||||
|
btnPayNfc.setOnClickListener {
|
||||||
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btnPayNfc.setOnClickListener(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,92 +5,139 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.api.mib.MibFinancingClient
|
import sh.sar.basedbank.api.mib.MibFinancingClient
|
||||||
|
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
|
||||||
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
|
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ceil
|
||||||
|
|
||||||
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
||||||
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class Mib(val deal: MibFinanceDeal) : Item()
|
||||||
|
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
private val expandedPositions = mutableSetOf<Int>()
|
private val expandedPositions = mutableSetOf<Int>()
|
||||||
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||||
minimumFractionDigits = 2
|
minimumFractionDigits = 2
|
||||||
maximumFractionDigits = 2
|
maximumFractionDigits = 2
|
||||||
}
|
}
|
||||||
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||||
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
|
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
|
||||||
|
|
||||||
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
deals = newDeals
|
if (hideAmounts == hide) return
|
||||||
expandedPositions.clear()
|
hideAmounts = hide
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||||
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
expandedPositions.clear()
|
||||||
return ViewHolder(binding)
|
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||||
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
// Legacy compatibility — used on initial empty construction
|
||||||
holder.bind(deals[position], position in expandedPositions)
|
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
||||||
holder.binding.root.setOnClickListener {
|
expandedPositions.clear()
|
||||||
|
val bmlItems = items.filterIsInstance<Item.Bml>()
|
||||||
|
items = newDeals.map { Item.Mib(it) } + bmlItems
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||||
|
expandedPositions.clear()
|
||||||
|
val mibItems = items.filterIsInstance<Item.Mib>()
|
||||||
|
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
|
is Item.Mib -> TYPE_MIB
|
||||||
|
is Item.Bml -> TYPE_BML
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return when (viewType) {
|
||||||
|
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
|
||||||
|
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
val expanded = position in expandedPositions
|
||||||
|
when (val item = items[position]) {
|
||||||
|
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
|
||||||
|
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
|
||||||
|
}
|
||||||
|
holder.itemView.setOnClickListener {
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
|
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
|
||||||
notifyItemChanged(pos)
|
notifyItemChanged(pos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = deals.size
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
inner class ViewHolder(val binding: ItemFinanceDealBinding) :
|
// ── MIB ViewHolder ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
||||||
val ctx = binding.root.context
|
val ctx = binding.root.context
|
||||||
val currency = deal.currency
|
val currency = deal.currency
|
||||||
|
val hide = hideAmounts
|
||||||
|
|
||||||
binding.tvProductName.text = deal.productDesc
|
binding.tvProductName.text = deal.productDesc
|
||||||
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
||||||
binding.tvStatus.text = deal.statusDesc
|
binding.tvStatus.text = deal.statusDesc
|
||||||
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
|
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||||
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
|
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||||
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||||
|
|
||||||
// Progress bar
|
|
||||||
val progress = if (deal.dealAmount > 0)
|
val progress = if (deal.dealAmount > 0)
|
||||||
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
||||||
else 0
|
else 0
|
||||||
binding.progressBar.progress = progress
|
binding.progressBar.progress = if (hide) 0 else progress
|
||||||
|
|
||||||
// Completion estimate
|
binding.tvCompletion.text = mibCompletionText(deal, ctx)
|
||||||
binding.tvCompletion.text = completionText(deal, ctx)
|
|
||||||
|
|
||||||
// Expanded details
|
|
||||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||||
binding.dividerDetails.visibility = detailsVisible
|
binding.dividerDetails.visibility = detailsVisible
|
||||||
binding.detailsGroup.visibility = detailsVisible
|
binding.detailsGroup.visibility = detailsVisible
|
||||||
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
binding.tvDealDate.text = formatDate(deal.dealDate)
|
binding.tvDealDate.text = formatMibDate(deal.dealDate)
|
||||||
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
|
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||||
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
||||||
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
binding.tvLastPaidDate.text = formatMibDate(deal.lastPaidDate)
|
||||||
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||||
|
|
||||||
if (deal.overdueAmount > 0) {
|
if (deal.overdueAmount > 0) {
|
||||||
binding.rowOverdue.visibility = View.VISIBLE
|
binding.rowOverdue.visibility = View.VISIBLE
|
||||||
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
|
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||||
} else {
|
} else {
|
||||||
binding.rowOverdue.visibility = View.GONE
|
binding.rowOverdue.visibility = View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
||||||
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
|
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
|
||||||
val remaining = MibFinancingClient.remainingMonths(deal)
|
val remaining = MibFinancingClient.remainingMonths(deal)
|
||||||
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||||
@@ -100,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
|||||||
return ctx.getString(R.string.financing_completion_fmt, month)
|
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDate(raw: String): String {
|
private fun formatMibDate(raw: String): String {
|
||||||
return try {
|
return try {
|
||||||
outputDateFmt.format(inputDateFmt.parse(raw)!!)
|
outputDateFmt.format(mibDateFmt.parse(raw)!!)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) { raw.take(10) }
|
||||||
raw.take(10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BML ViewHolder ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
|
||||||
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
|
|
||||||
|
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
|
||||||
|
val ctx = binding.root.context
|
||||||
|
val currency = account.currencyName
|
||||||
|
val hide = hideAmounts
|
||||||
|
|
||||||
|
binding.tvLoanProduct.text = account.accountTypeName
|
||||||
|
.trim().lowercase().split(" ")
|
||||||
|
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||||
|
binding.tvLoanAccount.text = account.accountNumber
|
||||||
|
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
|
||||||
|
|
||||||
|
val loanAmt = detail?.loanAmount ?: 0.0
|
||||||
|
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
|
||||||
|
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
|
||||||
|
|
||||||
|
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
|
||||||
|
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
|
||||||
|
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
|
||||||
|
|
||||||
|
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
|
||||||
|
binding.loanProgressBar.progress = if (hide) 0 else progress
|
||||||
|
|
||||||
|
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
|
||||||
|
|
||||||
|
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||||
|
binding.loanDividerDetails.visibility = detailsVisible
|
||||||
|
binding.loanDetailsGroup.visibility = detailsVisible
|
||||||
|
|
||||||
|
if (expanded && detail != null) {
|
||||||
|
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
|
||||||
|
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
|
||||||
|
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
|
||||||
|
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
|
||||||
|
|
||||||
|
if (detail.overdueAmount > 0) {
|
||||||
|
binding.loanRowOverdue.visibility = View.VISIBLE
|
||||||
|
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
|
||||||
|
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
|
||||||
|
} else {
|
||||||
|
binding.loanRowOverdue.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
|
||||||
|
if (detail == null) return ""
|
||||||
|
val outstanding = abs(detail.outstandingAmt)
|
||||||
|
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
|
||||||
|
return ctx.getString(R.string.financing_completion_done)
|
||||||
|
val remaining = ceil(outstanding / detail.repayAmount).toInt()
|
||||||
|
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
cal.add(Calendar.MONTH, remaining)
|
||||||
|
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
|
||||||
|
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatIsoDate(raw: String): String {
|
||||||
|
return try {
|
||||||
|
outputDateFmt.format(isoDateFmt.parse(raw)!!)
|
||||||
|
} catch (_: Exception) { raw.take(10) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_MIB = 0
|
||||||
|
private const val TYPE_BML = 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import android.os.Bundle
|
|||||||
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.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||||
|
|
||||||
class FinancingFragment : Fragment() {
|
class FinancingFragment : Fragment() {
|
||||||
@@ -16,6 +21,10 @@ class FinancingFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
private lateinit var adapter: FinancingAdapter
|
private lateinit var adapter: FinancingAdapter
|
||||||
|
private var financingRefreshing = false
|
||||||
|
|
||||||
|
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||||
|
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
|
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
|
||||||
@@ -27,11 +36,47 @@ class FinancingFragment : Fragment() {
|
|||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
financingRefreshing = true
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshFinancing()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
||||||
adapter.updateDeals(deals)
|
latestMibDeals = deals
|
||||||
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
|
rebuildAdapter()
|
||||||
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
}
|
||||||
binding.loadingView.visibility = View.GONE
|
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
|
||||||
|
latestBmlLoanDetails = details
|
||||||
|
rebuildAdapter()
|
||||||
|
}
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildAdapter() {
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
|
||||||
|
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||||
|
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||||
|
|
||||||
|
adapter.update(latestMibDeals, bmlLoans)
|
||||||
|
|
||||||
|
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||||
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
|
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||||
|
binding.loadingView.visibility = View.GONE
|
||||||
|
if (financingRefreshing) {
|
||||||
|
financingRefreshing = false
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,25 @@ package sh.sar.basedbank.ui.home
|
|||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel : ViewModel() {
|
||||||
val accounts = MutableLiveData<List<MibAccount>>(emptyList())
|
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||||
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList())
|
/** BML loan details keyed by account internalId. */
|
||||||
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList())
|
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||||
|
val contacts = MutableLiveData<List<BankContact>>(emptyList())
|
||||||
|
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
|
||||||
|
|
||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
|
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
||||||
|
|
||||||
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -7,35 +8,24 @@ import android.view.ViewGroup
|
|||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
class MoreFragment : Fragment() {
|
class MoreFragment : Fragment() {
|
||||||
|
|
||||||
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
|
|
||||||
|
|
||||||
private val items = listOf(
|
|
||||||
NavItem(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
|
|
||||||
NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
|
|
||||||
NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
|
|
||||||
NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
|
|
||||||
NavItem(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
|
|
||||||
NavItem(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
|
|
||||||
NavItem(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
inflater.inflate(R.layout.fragment_more, container, false)
|
inflater.inflate(R.layout.fragment_more, container, false)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val items = NavCustomization.getMoreItems(prefs)
|
||||||
val list = view.findViewById<LinearLayout>(R.id.moreList)
|
val list = view.findViewById<LinearLayout>(R.id.moreList)
|
||||||
val inflater = LayoutInflater.from(requireContext())
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
for (item in items) {
|
for (item in items) {
|
||||||
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
||||||
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
|
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
|
||||||
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
||||||
|
row.findViewById<TextView>(R.id.tvDescription).setText(item.descriptionRes)
|
||||||
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
|
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
|
||||||
list.addView(row)
|
list.addView(row)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
|
object NavCustomization {
|
||||||
|
|
||||||
|
data class NavItemDef(
|
||||||
|
val id: Int,
|
||||||
|
@DrawableRes val iconRes: Int,
|
||||||
|
@StringRes val titleRes: Int,
|
||||||
|
@StringRes val descriptionRes: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
|
val ALL_SWAPPABLE = listOf(
|
||||||
|
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||||
|
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||||
|
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||||
|
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||||
|
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
|
||||||
|
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
||||||
|
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
||||||
|
NavItemDef(R.id.nav_pay_with_card, R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
||||||
|
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
||||||
|
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
||||||
|
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
|
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
|
||||||
|
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
|
||||||
|
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
|
prefs.edit()
|
||||||
|
.putInt("bottom_nav_slot_1", slots[0])
|
||||||
|
.putInt("bottom_nav_slot_2", slots[1])
|
||||||
|
.putInt("bottom_nav_slot_3", slots[2])
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
|
prefs.getInt("quick_action_1", R.id.nav_transfer),
|
||||||
|
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
||||||
|
prefs.edit()
|
||||||
|
.putInt("quick_action_1", ids[0])
|
||||||
|
.putInt("quick_action_2", ids[1])
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
||||||
|
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||||
|
val slots = getSlots(prefs).toSet()
|
||||||
|
return ALL_SWAPPABLE.filter { it.id !in slots }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
@@ -16,7 +18,14 @@ class NavMoreSheetFragment : BottomSheetDialogFragment() {
|
|||||||
inflater.inflate(R.layout.sheet_nav_more, container, false)
|
inflater.inflate(R.layout.sheet_nav_more, container, false)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
view.findViewById<NavigationView>(R.id.navMoreView).setNavigationItemSelectedListener { item ->
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val items = NavCustomization.getMoreItems(prefs)
|
||||||
|
val navView = view.findViewById<NavigationView>(R.id.navMoreView)
|
||||||
|
navView.menu.clear()
|
||||||
|
items.forEachIndexed { i, item ->
|
||||||
|
navView.menu.add(Menu.NONE, item.id, i, item.titleRes).setIcon(item.iconRes)
|
||||||
|
}
|
||||||
|
navView.setNavigationItemSelectedListener { item ->
|
||||||
dismiss()
|
dismiss()
|
||||||
onNavigate?.invoke(item.itemId)
|
onNavigate?.invoke(item.itemId)
|
||||||
true
|
true
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import kotlinx.coroutines.isActive
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
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.BmlAccountClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||||
@@ -71,8 +72,9 @@ class OtpFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
val entries = mutableListOf<OtpEntry>()
|
val entries = mutableListOf<OtpEntry>()
|
||||||
store.loadMibCredentials()?.let { creds ->
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val name = store.loadMibFullName()
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
|
val name = store.loadMibFullName(loginId)
|
||||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||||
}
|
}
|
||||||
for (loginId in store.getBmlLoginIds()) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
@@ -88,20 +90,23 @@ class OtpFragment : Fragment() {
|
|||||||
// Fetch real names in background if not yet cached, then refresh labels
|
// Fetch real names in background if not yet cached, then refresh labels
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
var changed = false
|
var changed = false
|
||||||
if (store.loadMibFullName() == null) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
app.mibSession?.let { session ->
|
if (store.loadMibFullName(loginId) == null) {
|
||||||
|
val session = app.mibSessions[loginId] ?: continue
|
||||||
|
val flow = app.mibFlowFor(loginId)
|
||||||
val profile = withContext(Dispatchers.IO) {
|
val profile = withContext(Dispatchers.IO) {
|
||||||
try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null }
|
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (profile != null) {
|
if (profile != null) {
|
||||||
store.saveMibUserProfile(CredentialStore.MibUserProfile(
|
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
|
||||||
fullName = profile.fullName,
|
fullName = profile.fullName,
|
||||||
username = profile.username,
|
username = profile.username,
|
||||||
email = profile.email,
|
email = profile.email,
|
||||||
mobile = profile.mobile,
|
mobile = profile.mobile,
|
||||||
enrolled = profile.enrolled
|
enrolled = profile.enrolled
|
||||||
))
|
))
|
||||||
val idx = entries.indexOfFirst { it.seed == store.loadMibCredentials()?.otpSeed }
|
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
||||||
|
val idx = entries.indexOfFirst { it.seed == seed }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +115,7 @@ class OtpFragment : Fragment() {
|
|||||||
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
|
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
|
||||||
val session = app.bmlSessions[loginId] ?: continue
|
val session = app.bmlSessions[loginId] ?: continue
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
|
try { BmlAccountClient().fetchUserInfo(session) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
|
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
|
||||||
|
|||||||
422
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
422
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.*
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.*
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
|
||||||
|
class PayMvQrFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentPayMvQrBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private var selectedAccount: BankAccount? = null
|
||||||
|
private var generatedBitmap: Bitmap? = null
|
||||||
|
private var generateJob: Job? = null
|
||||||
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
if (qr == null || qr.accountNumber == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = qr.accountNumber,
|
||||||
|
displayName = qr.merchantName ?: qr.accountNumber,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
_binding = FragmentPayMvQrBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val basePaddingBottom = view.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
|
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||||
|
val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
|
||||||
|
else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||||
|
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
setupDropdown()
|
||||||
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
|
binding.btnShare.isEnabled = false
|
||||||
|
binding.btnSave.isEnabled = false
|
||||||
|
binding.btnShare.setOnClickListener { shareQr() }
|
||||||
|
binding.btnSave.setOnClickListener { saveQr() }
|
||||||
|
binding.btnScanQr.setOnClickListener {
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupDropdown() {
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
val eligible = accounts.filter {
|
||||||
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN"
|
||||||
|
}
|
||||||
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
|
binding.actvAccount.setAdapter(adapter)
|
||||||
|
binding.actvAccount.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
||||||
|
selectedAccount = picked
|
||||||
|
scheduleGenerate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleGenerate() {
|
||||||
|
generateJob?.cancel()
|
||||||
|
generateJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(300)
|
||||||
|
generateQr()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun generateQr() {
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
val acquirer = when (account.bank) {
|
||||||
|
"BML" -> "MALBMVMV"
|
||||||
|
"MIB" -> "MADVMVMV"
|
||||||
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
|
else -> "MADVMVMV"
|
||||||
|
}
|
||||||
|
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||||
|
?.replace(",", "")
|
||||||
|
?.toDoubleOrNull()
|
||||||
|
?.takeIf { it > 0 }
|
||||||
|
?.let { "%.2f".format(it) }
|
||||||
|
|
||||||
|
val ctx = requireContext()
|
||||||
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
|
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
||||||
|
renderQrCard(ctx, account, payload, amountFormatted)
|
||||||
|
}
|
||||||
|
if (_binding == null) return
|
||||||
|
generatedBitmap = bmp
|
||||||
|
binding.tvQrPlaceholder.visibility = View.GONE
|
||||||
|
binding.ivQrCard.setImageBitmap(bmp)
|
||||||
|
binding.ivQrCard.visibility = View.VISIBLE
|
||||||
|
binding.btnShare.isEnabled = true
|
||||||
|
binding.btnSave.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EMV MPQR payload ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildQrPayload(
|
||||||
|
accountNumber: String,
|
||||||
|
accountName: String,
|
||||||
|
acquirer: String,
|
||||||
|
amountStr: String?
|
||||||
|
): String {
|
||||||
|
fun tlv(tag: String, value: String): String {
|
||||||
|
val len = value.length
|
||||||
|
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||||
|
}
|
||||||
|
val format = tlv("00", "01")
|
||||||
|
val poi = tlv("01", "11")
|
||||||
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
|
val sub01 = tlv("01", acquirer)
|
||||||
|
val sub03 = tlv("03", accountNumber)
|
||||||
|
val sub10 = tlv("10", "IPAY")
|
||||||
|
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
||||||
|
val currency = tlv("53", "462")
|
||||||
|
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||||
|
val country = tlv("58", "MV")
|
||||||
|
val name = tlv("59", accountName.take(25))
|
||||||
|
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
|
||||||
|
return prefix + crc16(prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crc16(data: String): String {
|
||||||
|
var crc = 0xFFFF
|
||||||
|
for (c in data) {
|
||||||
|
crc = crc xor ((c.code and 0xFF) shl 8)
|
||||||
|
repeat(8) {
|
||||||
|
crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021
|
||||||
|
else (crc shl 1) and 0xFFFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc.toString(16).uppercase().padStart(4, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR card rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun renderQrCard(
|
||||||
|
ctx: Context,
|
||||||
|
account: BankAccount,
|
||||||
|
qrPayload: String,
|
||||||
|
amountStr: String?
|
||||||
|
): Bitmap {
|
||||||
|
val W = 900
|
||||||
|
val H = 1080
|
||||||
|
val outerCorner = 48f
|
||||||
|
val boxBlue = Color.parseColor("#2272B7")
|
||||||
|
val footerBlue = Color.parseColor("#1A5799")
|
||||||
|
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
||||||
|
|
||||||
|
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
// Clip to outer rounded card shape
|
||||||
|
val outerPath = Path()
|
||||||
|
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
||||||
|
canvas.clipPath(outerPath)
|
||||||
|
canvas.drawColor(Color.WHITE)
|
||||||
|
|
||||||
|
// --- Bank logo top-left ---
|
||||||
|
val logoRes = when (account.bank) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"MIB" -> R.drawable.mib_faisanet_logo
|
||||||
|
else -> R.drawable.fahipay_logo_long
|
||||||
|
}
|
||||||
|
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||||
|
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||||
|
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||||
|
val maxW = 180f; val maxH = 76f
|
||||||
|
val scale = minOf(maxW / nW, maxH / nH)
|
||||||
|
val lW = (nW * scale).toInt()
|
||||||
|
val lH = (nH * scale).toInt()
|
||||||
|
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
||||||
|
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
||||||
|
d.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- "PayMV QR" top-right ---
|
||||||
|
paint.color = Color.parseColor("#1A1A2E")
|
||||||
|
paint.textSize = 36f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.RIGHT
|
||||||
|
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||||
|
|
||||||
|
// --- Blue rounded box ---
|
||||||
|
paint.color = boxBlue
|
||||||
|
paint.textAlign = Paint.Align.LEFT
|
||||||
|
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||||
|
|
||||||
|
// Account name (white, bold, uppercase, auto-scaled to fit)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
val nameText = account.accountBriefName.uppercase()
|
||||||
|
paint.textSize = 36f
|
||||||
|
val maxNameW = boxR - boxL - 48f
|
||||||
|
if (paint.measureText(nameText) > maxNameW) {
|
||||||
|
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
||||||
|
}
|
||||||
|
val nameBaseline = boxT + 68f
|
||||||
|
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
||||||
|
|
||||||
|
// Optional amount below name
|
||||||
|
val qrTopY: Float
|
||||||
|
if (!amountStr.isNullOrBlank()) {
|
||||||
|
paint.textSize = 28f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||||
|
val amtBaseline = nameBaseline + 42f
|
||||||
|
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
||||||
|
qrTopY = amtBaseline + 20f
|
||||||
|
} else {
|
||||||
|
qrTopY = nameBaseline + 26f
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR code — white modules on the same blue as the box background
|
||||||
|
val availH = boxB - qrTopY - 24f
|
||||||
|
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
||||||
|
val qrLeft = ((W - qrPx) / 2).toFloat()
|
||||||
|
try {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||||
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
|
for (y in 0 until qrPx) {
|
||||||
|
for (x in 0 until qrPx) {
|
||||||
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
|
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||||
|
qrBm.recycle()
|
||||||
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
|
// --- Dark blue footer ---
|
||||||
|
paint.color = footerBlue
|
||||||
|
paint.textAlign = Paint.Align.LEFT
|
||||||
|
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.textSize = 32f
|
||||||
|
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
||||||
|
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share / Save ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun shareQr() {
|
||||||
|
val bmp = generatedBitmap ?: return
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val uri = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val dir = File(requireContext().cacheDir, "qr")
|
||||||
|
dir.mkdirs()
|
||||||
|
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
|
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||||
|
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
requireContext(),
|
||||||
|
"${requireContext().packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (uri == null || _binding == null) return@launch
|
||||||
|
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||||
|
type = "image/png"
|
||||||
|
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||||
|
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveQr() {
|
||||||
|
val bmp = generatedBitmap ?: return
|
||||||
|
val account = selectedAccount ?: return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val saved = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
|
val filename = "${safeName}_PayMV_QR.png"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val values = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
|
||||||
|
}
|
||||||
|
val uri = requireContext().contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
|
||||||
|
) ?: return@withContext false
|
||||||
|
requireContext().contentResolver.openOutputStream(uri)?.use {
|
||||||
|
bmp.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||||
|
dir.mkdirs()
|
||||||
|
FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.pay_mv_qr)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class QrAccountAdapter(
|
||||||
|
private val context: Context,
|
||||||
|
private val accounts: List<BankAccount>
|
||||||
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
fun getAccount(position: Int): BankAccount? = accounts.getOrNull(position)
|
||||||
|
|
||||||
|
override fun getCount() = accounts.size
|
||||||
|
override fun getItem(position: Int) = accounts.getOrNull(position)
|
||||||
|
override fun getItemId(position: Int) = position.toLong()
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||||
|
getDropDownView(position, convertView, parent)
|
||||||
|
|
||||||
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val acc = accounts[position]
|
||||||
|
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||||
|
convertView.tag as ItemAccountDropdownBinding
|
||||||
|
} else {
|
||||||
|
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
|
.also { it.root.tag = it }
|
||||||
|
}
|
||||||
|
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
|
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||||
|
b.tvDropdownBalance.text = ""
|
||||||
|
b.root.alpha = 1f
|
||||||
|
return b.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(c: CharSequence?) =
|
||||||
|
FilterResults().apply { values = accounts; count = accounts.size }
|
||||||
|
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||||
|
override fun convertResultToString(r: Any?) =
|
||||||
|
(r as? BankAccount)?.let {
|
||||||
|
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||||
|
"$prefix${it.accountBriefName}"
|
||||||
|
} ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
|
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding
|
||||||
|
import sh.sar.basedbank.util.CardsCache
|
||||||
|
|
||||||
|
class PayWithCardFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentPayWithCardBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val adapter = CardWalletAdapter(emptyList(), requireContext())
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.mibCards.observe(viewLifecycleOwner) { cards ->
|
||||||
|
if (cards == null) return@observe
|
||||||
|
adapter.update(cards)
|
||||||
|
binding.loadingView.visibility = View.GONE
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
val cached = CardsCache.load(requireContext())
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
viewModel.mibCards.value = cached
|
||||||
|
} else {
|
||||||
|
binding.loadingView.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_pay_with_card)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class CardWalletAdapter(
|
||||||
|
private var cards: List<MibCard>,
|
||||||
|
private val context: Context
|
||||||
|
) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
|
||||||
|
|
||||||
|
fun update(newCards: List<MibCard>) {
|
||||||
|
cards = newCards
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
||||||
|
VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false))
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||||
|
override fun getItemCount() = cards.size
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||||
|
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||||
|
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||||
|
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
|
||||||
|
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
|
||||||
|
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
|
||||||
|
|
||||||
|
fun bind(card: MibCard) {
|
||||||
|
tvCardOwner.text = card.cardHolderName
|
||||||
|
tvCardNumber.text = formatMasked(card.maskedCardNumber)
|
||||||
|
tvCardType.text = card.cardTypeDesc
|
||||||
|
val assetPath = cardImageAsset(card)
|
||||||
|
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
|
||||||
|
else ivCardImage.setImageDrawable(null)
|
||||||
|
btnPayQr.setOnClickListener {
|
||||||
|
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
|
||||||
|
val nfcSupported = nfcAdapter != null
|
||||||
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
|
if (nfcSupported) {
|
||||||
|
btnPayNfc.setOnClickListener {
|
||||||
|
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btnPayNfc.setOnClickListener(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
||||||
|
"53" -> "cards/mib/visa_black_platinum.jpg"
|
||||||
|
"57" -> "cards/mib/visa_blue_everyday.jpg"
|
||||||
|
"70" -> "cards/mib/visa_business.jpg"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadCardImage(imageView: ImageView, assetPath: String) {
|
||||||
|
try {
|
||||||
|
val bitmap = imageView.context.assets.open(assetPath).use {
|
||||||
|
BitmapFactory.decodeStream(it)
|
||||||
|
}
|
||||||
|
imageView.setImageBitmap(bitmap)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
imageView.setImageDrawable(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatMasked(masked: String): String {
|
||||||
|
if (masked.length < 4) return masked
|
||||||
|
return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ import android.widget.Toast
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.camera.core.ImageAnalysis
|
import androidx.camera.core.ImageAnalysis
|
||||||
import androidx.camera.core.Preview
|
import androidx.camera.core.Preview
|
||||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||||
@@ -84,8 +87,23 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
// Black camera background — always use light (white) system bar icons
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = false
|
||||||
|
isAppearanceLightNavigationBars = false
|
||||||
|
}
|
||||||
|
val originalBtnMarginBottom = (48 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.btnContainer) { view, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
(view.layoutParams as android.widget.FrameLayout.LayoutParams).also {
|
||||||
|
it.bottomMargin = originalBtnMarginBottom + bars.bottom
|
||||||
|
view.layoutParams = it
|
||||||
|
}
|
||||||
|
insets
|
||||||
|
}
|
||||||
binding.btnCancel.setOnClickListener { finish() }
|
binding.btnCancel.setOnClickListener { finish() }
|
||||||
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
|
||||||
|
import java.util.Collections
|
||||||
|
|
||||||
class SettingsAppearanceFragment : Fragment() {
|
class SettingsAppearanceFragment : Fragment() {
|
||||||
|
|
||||||
private var _binding: FragmentSettingsAppearanceBinding? = null
|
private var _binding: FragmentSettingsAppearanceBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private val slots = mutableListOf<Int>()
|
||||||
|
private val quickActions = mutableListOf<Int>()
|
||||||
|
private lateinit var slotAdapter: NavItemAdapter
|
||||||
|
private lateinit var quickActionAdapter: NavItemAdapter
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Navigation mode
|
// Navigation mode
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
@@ -32,8 +48,35 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||||
(activity as? HomeActivity)?.applyNavMode()
|
(activity as? HomeActivity)?.applyNavMode()
|
||||||
|
updateShortcutsVisibility()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick actions
|
||||||
|
quickActions.clear()
|
||||||
|
quickActions.addAll(NavCustomization.getQuickActions(prefs))
|
||||||
|
quickActionAdapter = NavItemAdapter(quickActions) {
|
||||||
|
NavCustomization.saveQuickActions(prefs, quickActions)
|
||||||
|
}
|
||||||
|
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
|
||||||
|
|
||||||
|
// Bottom bar shortcuts
|
||||||
|
slots.clear()
|
||||||
|
slots.addAll(NavCustomization.getSlots(prefs))
|
||||||
|
slotAdapter = NavItemAdapter(slots) {
|
||||||
|
NavCustomization.saveSlots(prefs, slots)
|
||||||
|
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||||
|
}
|
||||||
|
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
|
||||||
|
// Show labels toggle
|
||||||
|
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||||
|
binding.switchShowLabels.isChecked = showLabels
|
||||||
|
binding.switchShowLabels.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.edit().putBoolean("bottom_nav_show_labels", checked).apply()
|
||||||
|
(activity as? HomeActivity)?.applyNavLabelVisibility()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateShortcutsVisibility()
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
val saved = prefs.getString("theme", "system")
|
val saved = prefs.getString("theme", "system")
|
||||||
binding.themeToggle.check(when (saved) {
|
binding.themeToggle.check(when (saved) {
|
||||||
@@ -63,6 +106,95 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setupNavItemRecyclerView(
|
||||||
|
rv: RecyclerView,
|
||||||
|
adapter: NavItemAdapter,
|
||||||
|
items: MutableList<Int>
|
||||||
|
) {
|
||||||
|
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
|
rv.adapter = adapter
|
||||||
|
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||||
|
ItemTouchHelper.START or ItemTouchHelper.END, 0
|
||||||
|
) {
|
||||||
|
override fun onMove(
|
||||||
|
rv: RecyclerView,
|
||||||
|
from: RecyclerView.ViewHolder,
|
||||||
|
to: RecyclerView.ViewHolder
|
||||||
|
): Boolean {
|
||||||
|
val fromPos = from.adapterPosition
|
||||||
|
val toPos = to.adapterPosition
|
||||||
|
Collections.swap(items, fromPos, toPos)
|
||||||
|
adapter.notifyItemMoved(fromPos, toPos)
|
||||||
|
adapter.onSave()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
|
||||||
|
}).attachToRecyclerView(rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateShortcutsVisibility() {
|
||||||
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
|
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||||
|
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||||
|
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||||
|
val listLayout = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
|
||||||
|
val rows = available.map { item ->
|
||||||
|
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
|
||||||
|
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
|
||||||
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
||||||
|
listLayout.addView(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scroll = ScrollView(ctx).apply { addView(listLayout) }
|
||||||
|
var dialog: androidx.appcompat.app.AlertDialog? = null
|
||||||
|
dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.settings_bottom_bar_select)
|
||||||
|
.setView(scroll)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show()
|
||||||
|
available.forEachIndexed { i, item ->
|
||||||
|
rows[i].setOnClickListener {
|
||||||
|
items[slotIndex] = item.id
|
||||||
|
adapter.onSave()
|
||||||
|
adapter.notifyItemChanged(slotIndex)
|
||||||
|
dialog?.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class NavItemAdapter(
|
||||||
|
val items: MutableList<Int>,
|
||||||
|
val onSave: () -> Unit
|
||||||
|
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
|
||||||
|
|
||||||
|
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||||
|
val ivNavIcon: ImageView = view.findViewById(R.id.ivNavIcon)
|
||||||
|
val tvNavLabel: TextView = view.findViewById(R.id.tvNavLabel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_nav_slot, parent, false)
|
||||||
|
val itemWidth = if (parent.measuredWidth > 0) parent.measuredWidth / items.size
|
||||||
|
else RecyclerView.LayoutParams.WRAP_CONTENT
|
||||||
|
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||||
|
return VH(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
|
||||||
|
holder.ivNavIcon.setImageResource(def.iconRes)
|
||||||
|
holder.tvNavLabel.setText(def.titleRes)
|
||||||
|
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.settings_appearance)
|
requireActivity().title = getString(R.string.settings_appearance)
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ class SettingsFragment : Fragment() {
|
|||||||
private data class SettingsItem(
|
private data class SettingsItem(
|
||||||
@DrawableRes val icon: Int,
|
@DrawableRes val icon: Int,
|
||||||
@StringRes val title: Int,
|
@StringRes val title: Int,
|
||||||
|
@StringRes val description: Int,
|
||||||
val dest: () -> Fragment
|
val dest: () -> Fragment
|
||||||
)
|
)
|
||||||
|
|
||||||
private val items = listOf(
|
private val items = listOf(
|
||||||
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins) { SettingsLoginsFragment() },
|
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance) { SettingsAppearanceFragment() },
|
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
||||||
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security) { SettingsSecurityFragment() },
|
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage) { SettingsStorageFragment() },
|
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
@@ -37,6 +38,7 @@ class SettingsFragment : Fragment() {
|
|||||||
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
||||||
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
|
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
|
||||||
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
|
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
|
||||||
|
row.findViewById<TextView>(R.id.tvDescription).setText(item.description)
|
||||||
row.setOnClickListener {
|
row.setOnClickListener {
|
||||||
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
|
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.materialswitch.MaterialSwitch
|
||||||
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.BmlProfile
|
||||||
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
@@ -24,11 +28,21 @@ import sh.sar.basedbank.util.CredentialStore
|
|||||||
import sh.sar.basedbank.util.FinancingCache
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
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
|
||||||
|
|
||||||
class SettingsLoginsFragment : Fragment() {
|
class SettingsLoginsFragment : Fragment() {
|
||||||
|
|
||||||
private var _binding: FragmentSettingsLoginsBinding? = null
|
private var _binding: FragmentSettingsLoginsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||||
@@ -58,71 +72,45 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val container = binding.loginsContainer
|
val container = binding.loginsContainer
|
||||||
container.removeAllViews()
|
container.removeAllViews()
|
||||||
|
|
||||||
val hasMib = store.hasMibCredentials()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val hasFahipay = store.hasFahipayCredentials()
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) View.VISIBLE else View.GONE
|
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
if (hasMib) {
|
for (loginId in mibLoginIds) {
|
||||||
val profile = store.loadMibUserProfile()
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
val profileNames = AccountCache.load(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
val mibProfiles = store.loadMibProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
||||||
showLoginDetails(
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
title = getString(R.string.mib_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)}: ${profile!!.email}")
|
|
||||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
|
|
||||||
if (profileNames.isNotEmpty()) {
|
|
||||||
appendLine()
|
|
||||||
appendLine(getString(R.string.login_detail_profiles))
|
|
||||||
profileNames.forEach { appendLine(" • $it") }
|
|
||||||
}
|
|
||||||
}.trim(),
|
|
||||||
onLogout = { confirmLogout(getString(R.string.mib_name)) { logoutMib(store) } }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
for (loginId in bmlLoginIds) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct()
|
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||||
showLoginDetails(
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
title = getString(R.string.bml_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)}: ${profile!!.email}")
|
|
||||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}")
|
|
||||||
if (!profile?.customerId.isNullOrBlank()) appendLine("${getString(R.string.login_detail_customer_id)}: ${profile!!.customerId}")
|
|
||||||
if (!profile?.idCard.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.idCard}")
|
|
||||||
if (profileNames.isNotEmpty()) {
|
|
||||||
appendLine()
|
|
||||||
appendLine(getString(R.string.login_detail_profiles))
|
|
||||||
profileNames.forEach { appendLine(" • $it") }
|
|
||||||
}
|
|
||||||
}.trim(),
|
|
||||||
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFahipay) {
|
for (loginId in fahipayLoginIds) {
|
||||||
val profile = store.loadFahipayUserProfile()
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val masked = "••••••"
|
||||||
showLoginDetails(
|
showLoginDetails(
|
||||||
title = getString(R.string.fahipay_name),
|
title = getString(R.string.fahipay_name),
|
||||||
details = buildString {
|
details = buildString {
|
||||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}")
|
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)}: ${profile!!.mobile}")
|
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)}: ${profile!!.nid}")
|
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
|
||||||
}.trim(),
|
}.trim(),
|
||||||
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store) } }
|
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +142,478 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
container.addView(row)
|
container.addView(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showMibLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.MibUserProfile?,
|
||||||
|
mibProfiles: List<MibProfile>
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||||
|
val hidden = originalHidden.toMutableSet()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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)}: $it" },
|
||||||
|
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: $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).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mibProfiles.isNotEmpty()) {
|
||||||
|
if (profile != null) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.login_detail_profiles)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
|
||||||
|
val toggleRows = mibProfiles.map { p ->
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.name
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
})
|
||||||
|
if (p.cifType.isNotBlank()) {
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.cifType
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
p to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
// Disable the sole remaining visible toggle so it can't be turned off
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.mib_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.setPositiveButton(R.string.save, null) // null — set manually after show()
|
||||||
|
.setNeutralButton(R.string.close, null)
|
||||||
|
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.mib_name)) { logoutMib(store, loginId) }
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
store.setHiddenMibProfileIds(loginId, hidden)
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.relogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBmlLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.BmlUserProfile?,
|
||||||
|
bmlProfiles: List<BmlProfile>
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val hidden = store.getHiddenBmlProfileIds(loginId).toMutableSet()
|
||||||
|
// Business profiles with no saved session were skipped during login — ensure they start hidden
|
||||||
|
val needsActivation = bmlProfiles
|
||||||
|
.filter { it.profileType == "business" && store.loadBmlProfileSession(it.profileId) == null }
|
||||||
|
.map { it.profileId }
|
||||||
|
.toMutableSet()
|
||||||
|
for (id in needsActivation) {
|
||||||
|
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
|
}
|
||||||
|
val originalHidden = hidden.toSet()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val masked = "••••••"
|
||||||
|
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?.customerId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_customer_id)}: ${if (hide) masked else it}" },
|
||||||
|
profile?.idCard?.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).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bmlProfiles.isNotEmpty()) {
|
||||||
|
if (profile != null) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.login_detail_profiles)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleRows = bmlProfiles.map { p ->
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.name
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
})
|
||||||
|
if (p.type.isNotBlank()) {
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = p.type
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
p to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||||
|
toggleRows.forEach { (_, toggle) ->
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.bml_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.setPositiveButton(R.string.save, null)
|
||||||
|
.setNeutralButton(R.string.close, null)
|
||||||
|
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) }
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (p, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked && p.profileId in needsActivation) {
|
||||||
|
toggle.isChecked = false // revert — enabling requires OTP
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val success = activateBmlBusinessProfile(store, loginId, p)
|
||||||
|
if (success) {
|
||||||
|
needsActivation.remove(p.profileId)
|
||||||
|
toggle.isChecked = true // listener re-fires, removes from hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (checked) hidden.remove(p.profileId) else hidden.add(p.profileId)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.relogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun activateBmlBusinessProfile(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: BmlProfile
|
||||||
|
): Boolean {
|
||||||
|
val creds = store.loadBmlCredentials(loginId) ?: run {
|
||||||
|
showSimpleError("Credentials not found — please log out and log in again")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val progressDialog = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setMessage("Connecting to BML\u2026")
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
val flow = BmlLoginFlow()
|
||||||
|
val loginTag = "bml_$loginId"
|
||||||
|
val activationResult = try {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
flow.login(creds.username, creds.password, creds.otpSeed)
|
||||||
|
flow.activateProfile(profile, loginTag)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
progressDialog.dismiss()
|
||||||
|
showSimpleError(e.message ?: "Authentication failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
progressDialog.dismiss()
|
||||||
|
return when (activationResult) {
|
||||||
|
is BmlActivationResult.Success -> {
|
||||||
|
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
|
||||||
|
if (activationResult.session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, activationResult.session.refreshToken)
|
||||||
|
if (activationResult.session.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, activationResult.session.expiresAt)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
is BmlActivationResult.NeedsBusinessOtp ->
|
||||||
|
continueBmlBusinessOtpFlow(store, loginId, profile, flow, loginTag, activationResult.channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun continueBmlBusinessOtpFlow(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: BmlProfile,
|
||||||
|
flow: BmlLoginFlow,
|
||||||
|
loginTag: String,
|
||||||
|
channels: List<BmlOtpChannel>
|
||||||
|
): Boolean {
|
||||||
|
val selectedChannel = showBmlChannelSelectionDialog(profile.name, channels) ?: return false
|
||||||
|
val channelObj = channels.first { it.channel == selectedChannel }
|
||||||
|
val sendProgress = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setMessage("Sending OTP\u2026")
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { flow.requestBusinessOtp(selectedChannel) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendProgress.dismiss()
|
||||||
|
showSimpleError(e.message ?: "Failed to send OTP")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
sendProgress.dismiss()
|
||||||
|
var otpError: String? = null
|
||||||
|
while (true) {
|
||||||
|
val code = showBmlOtpInputDialog(profile.name, channelObj, otpError) ?: return false
|
||||||
|
val verifyProgress = MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setMessage("Verifying\u2026")
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
try {
|
||||||
|
val (session, _) = withContext(Dispatchers.IO) {
|
||||||
|
flow.submitBusinessOtp(selectedChannel, code, profile, loginTag)
|
||||||
|
}
|
||||||
|
verifyProgress.dismiss()
|
||||||
|
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
|
||||||
|
if (session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, session.refreshToken)
|
||||||
|
if (session.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, session.expiresAt)
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
verifyProgress.dismiss()
|
||||||
|
if (e.message?.contains("Invalid OTP") == true) {
|
||||||
|
otpError = e.message
|
||||||
|
} else {
|
||||||
|
showSimpleError(e.message ?: "Verification failed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSimpleError(message: String) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setMessage(message)
|
||||||
|
.setPositiveButton(R.string.close, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showBmlChannelSelectionDialog(profileName: String, channels: List<BmlOtpChannel>): String? =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val vp = (8 * dp).toInt()
|
||||||
|
setPadding(0, vp, 0, vp)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (channel in channels) {
|
||||||
|
val iconRes = when (channel.channel) {
|
||||||
|
"Email" -> R.drawable.ic_channel_email
|
||||||
|
"Mobile" -> R.drawable.ic_channel_sms
|
||||||
|
"WhatsApp" -> R.drawable.ic_channel_whatsapp
|
||||||
|
else -> R.drawable.ic_channel_sms
|
||||||
|
}
|
||||||
|
val iconSize = (24 * dp).toInt()
|
||||||
|
|
||||||
|
val iconView = ImageView(ctx).apply {
|
||||||
|
setImageResource(iconRes)
|
||||||
|
}
|
||||||
|
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||||
|
marginStart = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.description
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.masked
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
|
||||||
|
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 vp = (12 * dp).toInt()
|
||||||
|
setPadding(hp, vp, hp, vp)
|
||||||
|
}
|
||||||
|
row.addView(iconView, LinearLayout.LayoutParams(iconSize, iconSize))
|
||||||
|
row.addView(textCol)
|
||||||
|
list.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
val d = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle("Send verification code")
|
||||||
|
.setView(list)
|
||||||
|
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
|
||||||
|
|
||||||
|
// Wire up row clicks after dialog is created so we can dismiss it first
|
||||||
|
val rows = list.run { (0 until childCount).map { getChildAt(it) } }
|
||||||
|
rows.forEachIndexed { i, row ->
|
||||||
|
row.setOnClickListener {
|
||||||
|
d.dismiss()
|
||||||
|
if (cont.isActive) cont.resume(channels[i].channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun showBmlOtpInputDialog(
|
||||||
|
profileName: String,
|
||||||
|
channel: BmlOtpChannel,
|
||||||
|
errorMsg: String? = null
|
||||||
|
): String? = suspendCancellableCoroutine { cont ->
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val input = android.widget.EditText(ctx).apply {
|
||||||
|
hint = "Enter OTP"
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||||
|
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
|
||||||
|
setPadding((24 * dp).toInt(), (8 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||||
|
}
|
||||||
|
val msg = buildString {
|
||||||
|
append(getString(R.string.bml_business_otp_sent, channel.description))
|
||||||
|
append(" (${channel.masked})")
|
||||||
|
if (errorMsg != null) append("\n\n$errorMsg")
|
||||||
|
}
|
||||||
|
val d = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle("Enter verification code")
|
||||||
|
.setMessage(msg)
|
||||||
|
.setView(input)
|
||||||
|
.setPositiveButton(R.string.verify, null)
|
||||||
|
.setNegativeButton(R.string.cancel) { _, _ -> if (cont.isActive) cont.resume(null) }
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
d.setOnCancelListener { if (cont.isActive) cont.resume(null) }
|
||||||
|
d.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
val code = input.text.toString().trim()
|
||||||
|
if (code.length != 6) {
|
||||||
|
input.error = "Enter 6 digits"
|
||||||
|
} else {
|
||||||
|
d.dismiss()
|
||||||
|
if (cont.isActive) cont.resume(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
@@ -172,12 +632,16 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logoutMib(store: CredentialStore) {
|
private fun logoutMib(store: CredentialStore, loginId: String) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
store.clearMibCredentials()
|
store.clearMibCredentials(loginId)
|
||||||
ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply()
|
ctx.getSharedPreferences("mib_prefs", Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.accounts = emptyList(); app.mibSession = null; app.mibProfiles = emptyList()
|
app.mibSessions.remove(loginId)
|
||||||
|
app.mibProfilesMap.remove(loginId)
|
||||||
|
app.mibLoginFlows.remove(loginId)
|
||||||
|
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
@@ -185,20 +649,26 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
|
|
||||||
private fun logoutBml(store: CredentialStore, loginId: String) {
|
private fun logoutBml(store: CredentialStore, loginId: String) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.bmlSessions.remove(loginId)
|
// 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) }
|
||||||
|
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||||
|
store.clearBmlCredentials(loginId)
|
||||||
|
app.bmlProfilesMap.remove(loginId)
|
||||||
|
app.bmlLoginFlows.remove(loginId)
|
||||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun logoutFahipay(store: CredentialStore) {
|
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
store.clearFahipayCredentials(); store.clearFahipaySession()
|
store.clearFahipayCredentials(loginId)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.fahipaySession = null; app.fahipayAccounts = emptyList()
|
app.fahipaySessions.remove(loginId)
|
||||||
|
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != "fahipay_$loginId" }
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
(activity as HomeActivity).relogin()
|
(activity as HomeActivity).relogin()
|
||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
|
|||||||
@@ -31,12 +31,19 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto unlock on correct PIN (only for pin method)
|
||||||
|
if (prefs.getString("security_method", null) == "pin") {
|
||||||
|
binding.rowAutoUnlockPin.visibility = View.VISIBLE
|
||||||
|
binding.switchAutoUnlockPin.isChecked = prefs.getBoolean("auto_unlock_pin", false)
|
||||||
|
binding.switchAutoUnlockPin.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("auto_unlock_pin", isChecked).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Biometrics
|
// Biometrics
|
||||||
val canUseBiometrics = BiometricManager.from(requireContext())
|
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
if (canUseBiometrics) {
|
if (canUseBiometrics) {
|
||||||
binding.rowBiometrics.visibility = View.VISIBLE
|
|
||||||
|
|
||||||
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
binding.switchBiometrics.isChecked = unlockEnabled
|
binding.switchBiometrics.isChecked = unlockEnabled
|
||||||
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
@@ -54,11 +61,14 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||||
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||||
|
binding.switchBiometrics.isEnabled = false
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-lock
|
// Auto-lock
|
||||||
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
|
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
|
||||||
0L -> R.id.btnAutolockOff
|
|
||||||
30_000L -> R.id.btnAutolock30s
|
30_000L -> R.id.btnAutolock30s
|
||||||
180_000L -> R.id.btnAutolock3m
|
180_000L -> R.id.btnAutolock3m
|
||||||
300_000L -> R.id.btnAutolock5m
|
300_000L -> R.id.btnAutolock5m
|
||||||
@@ -67,7 +77,6 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
val timeout = when (checkedId) {
|
val timeout = when (checkedId) {
|
||||||
R.id.btnAutolockOff -> 0L
|
|
||||||
R.id.btnAutolock30s -> 30_000L
|
R.id.btnAutolock30s -> 30_000L
|
||||||
R.id.btnAutolock3m -> 180_000L
|
R.id.btnAutolock3m -> 180_000L
|
||||||
R.id.btnAutolock5m -> 300_000L
|
R.id.btnAutolock5m -> 300_000L
|
||||||
@@ -77,6 +86,17 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.resetAutolockTimer()
|
(activity as? HomeActivity)?.resetAutolockTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide sensitive information (enables/disables the eye icon in toolbar)
|
||||||
|
val viewModel = (requireActivity() as HomeActivity).let {
|
||||||
|
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
|
||||||
|
}
|
||||||
|
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
|
||||||
|
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
|
||||||
|
if (!isChecked) viewModel.hideAmounts.value = false
|
||||||
|
requireActivity().invalidateOptionsMenu()
|
||||||
|
}
|
||||||
|
|
||||||
// Block screenshots
|
// Block screenshots
|
||||||
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||||
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||||
|
|||||||
@@ -9,17 +9,17 @@ import android.view.ViewGroup
|
|||||||
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
|
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.models.BankTransaction
|
||||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||||
|
|
||||||
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
|
/** Adapter for BankTransaction History — date-grouped, shows account name in secondary line. */
|
||||||
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class DateHeader(val label: String) : Item()
|
data class DateHeader(val label: String) : Item()
|
||||||
data class Trx(val transaction: Transaction) : Item()
|
data class Trx(val transaction: BankTransaction) : Item()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
@@ -60,7 +67,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<BankTransaction>) {
|
||||||
_showLoadingFooter = false
|
_showLoadingFooter = false
|
||||||
displayItems.clear()
|
displayItems.clear()
|
||||||
var lastDateKey = ""
|
var lastDateKey = ""
|
||||||
@@ -107,7 +114,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
|
|
||||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(trx: Transaction) {
|
fun bind(trx: BankTransaction) {
|
||||||
val isCredit = trx.amount >= 0
|
val isCredit = trx.amount >= 0
|
||||||
val color = AccountHistoryAdapter.sourceColor(trx.source)
|
val color = AccountHistoryAdapter.sourceColor(trx.source)
|
||||||
val name = trx.counterpartyName ?: trx.description
|
val name = trx.counterpartyName ?: trx.description
|
||||||
@@ -134,23 +141,28 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||||||
}
|
}
|
||||||
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 BankTransaction History
|
||||||
b.tvCounterparty.text = trx.accountDisplayName
|
b.tvCounterparty.text = trx.accountDisplayName
|
||||||
b.tvCounterparty.visibility = View.VISIBLE
|
b.tvCounterparty.visibility = View.VISIBLE
|
||||||
|
|
||||||
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
||||||
|
|
||||||
val sign = if (isCredit) "+" else "-"
|
if (hideAmounts) {
|
||||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||||
b.tvAmount.setTextColor(
|
} else {
|
||||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
val sign = if (isCredit) "+" else "-"
|
||||||
)
|
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||||
|
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||||
|
b.tvAmount.setTextColor(
|
||||||
|
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
b.root.setOnClickListener { showDetail(trx) }
|
b.root.setOnClickListener { showDetail(trx) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDetail(trx: Transaction) {
|
private fun showDetail(trx: BankTransaction) {
|
||||||
val ctx = b.root.context
|
val ctx = b.root.context
|
||||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||||
val details = buildString {
|
val details = buildString {
|
||||||
|
|||||||
@@ -15,14 +15,18 @@ import android.widget.BaseAdapter
|
|||||||
import android.widget.Filter
|
import android.widget.Filter
|
||||||
import android.widget.Filterable
|
import android.widget.Filterable
|
||||||
import android.graphics.Typeface
|
import android.graphics.Typeface
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -34,12 +38,15 @@ import kotlinx.coroutines.launch
|
|||||||
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.BmlAccountClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlTransferResult
|
||||||
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferResult
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
import sh.sar.basedbank.api.mib.MibLookupException
|
import sh.sar.basedbank.api.mib.MibLookupException
|
||||||
@@ -48,11 +55,13 @@ import sh.sar.basedbank.api.mib.MibTransferResult
|
|||||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import sh.sar.basedbank.util.RecentPick
|
import sh.sar.basedbank.util.RecentPick
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class TransferFragment : Fragment() {
|
class TransferFragment : Fragment() {
|
||||||
@@ -61,9 +70,11 @@ class TransferFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private var selectedAccount: MibAccount? = null
|
private var selectedAccount: BankAccount? = null
|
||||||
private val session get() = (requireActivity().application as BasedBankApp).mibSession
|
private val session get() = selectedAccount
|
||||||
private fun bmlSessionFor(account: MibAccount?) =
|
?.let { (requireActivity().application as BasedBankApp).mibSessionFor(it) }
|
||||||
|
?: (requireActivity().application as BasedBankApp).anyMibSession()
|
||||||
|
private fun bmlSessionFor(account: BankAccount?) =
|
||||||
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
|
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
|
||||||
?: (requireActivity().application as BasedBankApp).anyBmlSession()
|
?: (requireActivity().application as BasedBankApp).anyBmlSession()
|
||||||
|
|
||||||
@@ -76,6 +87,28 @@ class TransferFragment : Fragment() {
|
|||||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||||
private var selectedFahipayService: String? = null
|
private var selectedFahipayService: String? = null
|
||||||
|
|
||||||
|
// BML business profile OTP flow state
|
||||||
|
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
|
private var bmlOtpState = BmlOtpState.NONE
|
||||||
|
private var bmlOtpChannel: String? = null
|
||||||
|
|
||||||
|
private data class PendingBmlTransfer(
|
||||||
|
val src: BankAccount,
|
||||||
|
val debitAccount: String,
|
||||||
|
val creditAccount: String,
|
||||||
|
val amount: Double,
|
||||||
|
val amountStr: String,
|
||||||
|
val remarks: String,
|
||||||
|
val transferType: String,
|
||||||
|
val currency: String,
|
||||||
|
val bank: String?,
|
||||||
|
val destDisplay: String,
|
||||||
|
val destAccount: String,
|
||||||
|
val toBank: String,
|
||||||
|
val toAvatar: Bitmap?
|
||||||
|
)
|
||||||
|
private var pendingBmlTransfer: PendingBmlTransfer? = null
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
@@ -96,8 +129,10 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_COLOR = "contact_color"
|
private const val ARG_COLOR = "contact_color"
|
||||||
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
private const val ARG_IMAGE_HASH = "contact_image_hash"
|
||||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||||
|
private const val ARG_AMOUNT_PREFILL = "amount_prefill"
|
||||||
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
|
|
||||||
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
|
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +151,22 @@ class TransferFragment : Fragment() {
|
|||||||
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
|
if (imageHash != null) putString(ARG_IMAGE_HASH, imageHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun newInstanceFromQr(
|
||||||
|
accountNumber: String,
|
||||||
|
displayName: String,
|
||||||
|
amount: String?,
|
||||||
|
remarks: String?
|
||||||
|
) = TransferFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
|
putString(ARG_NAME, displayName)
|
||||||
|
putString(ARG_SUBTITLE, accountNumber)
|
||||||
|
putString(ARG_COLOR, "#607D8B")
|
||||||
|
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||||
|
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
@@ -145,9 +196,15 @@ class TransferFragment : Fragment() {
|
|||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnTransfer.setOnClickListener { initiateTransfer() }
|
binding.btnTransfer.isEnabled = false
|
||||||
|
binding.btnTransfer.setOnClickListener {
|
||||||
|
if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp()
|
||||||
|
else initiateTransfer()
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-select contact if navigated from contacts page
|
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
||||||
|
|
||||||
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
accountNumber = account,
|
accountNumber = account,
|
||||||
@@ -157,6 +214,24 @@ class TransferFragment : Fragment() {
|
|||||||
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
imageHash = arguments?.getString(ARG_IMAGE_HASH)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLookupLoading() {
|
||||||
|
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||||
|
setStyle(CircularProgressDrawable.DEFAULT)
|
||||||
|
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
binding.tilTo.endIconDrawable = spinner
|
||||||
|
binding.tilTo.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLookupLoading() {
|
||||||
|
binding.tilTo.isEnabled = true
|
||||||
|
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupFromDropdown() {
|
private fun setupFromDropdown() {
|
||||||
@@ -166,6 +241,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.cardFromInfo.visibility = View.GONE
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
binding.tilFrom.visibility = View.VISIBLE
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
binding.actvFrom.setText("", false)
|
binding.actvFrom.setText("", false)
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
@@ -177,6 +253,7 @@ class TransferFragment : Fragment() {
|
|||||||
selectedAccount = picked
|
selectedAccount = picked
|
||||||
updateAmountPrefix(picked)
|
updateAmountPrefix(picked)
|
||||||
showFromCard(picked)
|
showFromCard(picked)
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||||
@@ -186,22 +263,22 @@ class TransferFragment : Fragment() {
|
|||||||
selectedAccount = match
|
selectedAccount = match
|
||||||
updateAmountPrefix(match)
|
updateAmountPrefix(match)
|
||||||
showFromCard(match)
|
showFromCard(match)
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFromCard(account: MibAccount) {
|
private fun showFromCard(account: BankAccount) {
|
||||||
val isBml = account.profileType.startsWith("BML")
|
val colorHex = when (account.bank) {
|
||||||
val colorHex = when {
|
"BML" -> "#0066A1"
|
||||||
isBml -> "#0066A1"
|
"FAHIPAY" -> "#15BEA7"
|
||||||
account.profileType == "FAHIPAY" -> "#15BEA7"
|
else -> "#FE860E"
|
||||||
else -> "#FE860E"
|
|
||||||
}
|
}
|
||||||
val bankLabel = when {
|
val bankLabel = when (account.bank) {
|
||||||
isBml -> "BML"
|
"BML" -> "BML"
|
||||||
account.profileType == "FAHIPAY" -> "FP"
|
"FAHIPAY" -> "FP"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val typeLabel = when {
|
val typeLabel = when {
|
||||||
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
account.profileType == "BML_PREPAID" -> "Prepaid Card"
|
||||||
@@ -217,7 +294,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilFrom.visibility = View.GONE
|
binding.tilFrom.visibility = View.GONE
|
||||||
binding.cardFromInfo.visibility = View.VISIBLE
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
if (!isBml && account.profileImageHash != null) {
|
if (account.bank != "BML" && account.profileImageHash != null) {
|
||||||
loadFromPhoto(account.profileImageHash)
|
loadFromPhoto(account.profileImageHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +304,7 @@ class TransferFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = app.mibLoginFlow.fetchProfileImage(sess, hash) ?: return@launch
|
val base64 = app.anyMibFlow()?.fetchProfileImage(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
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
@@ -237,7 +314,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateAmountPrefix(account: MibAccount) {
|
private fun updateAmountPrefix(account: BankAccount) {
|
||||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +331,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.etTo.addTextChangedListener {
|
binding.etTo.addTextChangedListener {
|
||||||
@@ -265,6 +343,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +374,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fahipay source: only phone numbers are supported
|
// Fahipay source: only phone numbers are supported
|
||||||
if (selectedAccount?.profileType == "FAHIPAY") {
|
if (selectedAccount?.bank == "FAHIPAY") {
|
||||||
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
|
if (AccountInputParser.detect(accountNumber) == AccountInputParser.InputType.PHONE) {
|
||||||
lookupFahipayTarget(accountNumber)
|
lookupFahipayTarget(accountNumber)
|
||||||
} else {
|
} else {
|
||||||
@@ -311,19 +390,18 @@ class TransferFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
|
val isBmlSource = selectedAccount?.bank == "BML"
|
||||||
|
|
||||||
binding.tilTo.isEnabled = false
|
startLookupLoading()
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
var errorMsg: String? = null
|
var errorMsg: String? = null
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
if (isBmlSource && bmlSess != null) {
|
if (isBmlSource && bmlSess != null) {
|
||||||
val inputType = AccountInputParser.detect(accountNumber)
|
val inputType = AccountInputParser.detect(accountNumber)
|
||||||
val bmlFlow = BmlLoginFlow()
|
|
||||||
val bmlResult = try {
|
val bmlResult = try {
|
||||||
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) bmlFlow.verifyMibAccount(bmlSess, accountNumber)
|
if (inputType == AccountInputParser.InputType.MIB_ACCOUNT) BmlValidateClient().verifyMibAccount(bmlSess, accountNumber)
|
||||||
else bmlFlow.validateAccount(bmlSess, accountNumber)
|
else BmlValidateClient().validateAccount(bmlSess, accountNumber)
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
if (bmlResult != null) {
|
if (bmlResult != null) {
|
||||||
val bankId = when (bmlResult.trnType) {
|
val bankId = when (bmlResult.trnType) {
|
||||||
@@ -344,7 +422,7 @@ class TransferFragment : Fragment() {
|
|||||||
catch (e: MibLookupException) { errorMsg = e.message; null }
|
catch (e: MibLookupException) { errorMsg = e.message; null }
|
||||||
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
|
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
|
||||||
} else {
|
} else {
|
||||||
val bmlResult = try { BmlLoginFlow().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
|
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
|
||||||
if (bmlResult != null) {
|
if (bmlResult != null) {
|
||||||
val bankId = when (bmlResult.trnType) {
|
val bankId = when (bmlResult.trnType) {
|
||||||
"IAT" -> "MALBMVMV"
|
"IAT" -> "MALBMVMV"
|
||||||
@@ -357,7 +435,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.tilTo.isEnabled = true
|
stopLookupLoading()
|
||||||
if (info != null) {
|
if (info != null) {
|
||||||
val accounts = viewModel.accounts.value ?: emptyList()
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
|
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
|
||||||
@@ -377,6 +455,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
updateTransferButton()
|
||||||
saveToRecents(info)
|
saveToRecents(info)
|
||||||
|
|
||||||
when {
|
when {
|
||||||
@@ -392,7 +471,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupFahipayTarget(number: String) {
|
private fun lookupFahipayTarget(number: String) {
|
||||||
binding.tilTo.isEnabled = false
|
startLookupLoading()
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
data class LookupResult(
|
data class LookupResult(
|
||||||
val dhiraagu: DhiraaguClient.Result,
|
val dhiraagu: DhiraaguClient.Result,
|
||||||
@@ -419,7 +498,7 @@ class TransferFragment : Fragment() {
|
|||||||
LookupResult(d, o)
|
LookupResult(d, o)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.tilTo.isEnabled = true
|
stopLookupLoading()
|
||||||
|
|
||||||
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
@@ -503,6 +582,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
updateTransferButton()
|
||||||
|
|
||||||
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
||||||
if (contact != null) {
|
if (contact != null) {
|
||||||
@@ -550,7 +630,8 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilAmount.error = null
|
binding.tilAmount.error = null
|
||||||
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
|
||||||
|
|
||||||
val isSrcBml = src.profileType.startsWith("BML")
|
val isSrcBml = src.bank == "BML"
|
||||||
|
val isBmlBusiness = isSrcBml && isBusinessProfile(src) // to test on personal accounts: use `isSrcBml`
|
||||||
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
|
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
|
||||||
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
|
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||||
val currency = src.currencyName.ifBlank { "MVR" }
|
val currency = src.currencyName.ifBlank { "MVR" }
|
||||||
@@ -561,10 +642,10 @@ class TransferFragment : Fragment() {
|
|||||||
if (isSrcBml && isDestMib && currency == "USD") {
|
if (isSrcBml && isDestMib && currency == "USD") {
|
||||||
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
|
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
|
||||||
if (!hasBmlContact) {
|
if (!hasBmlContact) {
|
||||||
AlertDialog.Builder(requireContext())
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(R.string.transfer_bml_contact_required_title)
|
.setTitle(R.string.transfer_bml_contact_required_title)
|
||||||
.setMessage(R.string.transfer_bml_contact_required_msg)
|
.setMessage(R.string.transfer_bml_contact_required_msg)
|
||||||
.setPositiveButton("OK", null)
|
.setPositiveButton(R.string.close, null)
|
||||||
.show()
|
.show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -585,28 +666,39 @@ class TransferFragment : Fragment() {
|
|||||||
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
||||||
|
|
||||||
val doTransfer: () -> Unit = {
|
val doTransfer: () -> Unit = {
|
||||||
binding.btnTransfer.isEnabled = false
|
if (isBmlBusiness) {
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
// Business profile: async OTP channel selection flow
|
||||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
startBmlBusinessOtpFlow(
|
||||||
if (!isSrcBml) {
|
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
|
||||||
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
|
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
|
||||||
} else {
|
)
|
||||||
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
|
if (!isSrcBml) {
|
||||||
|
doMibTransfer(src, resolvedAccountNumber, resolvedRecipientName, destDisplay, amountStr, remarks, bankNameCapture)
|
||||||
|
} else {
|
||||||
|
doBmlTransfer(src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
|
clearForm()
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.triggerRefresh()
|
||||||
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
|
} else if (!ok) {
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
binding.btnTransfer.isEnabled = true
|
|
||||||
if (ok && receipt != null) {
|
|
||||||
clearForm()
|
|
||||||
val activity = requireActivity() as HomeActivity
|
|
||||||
activity.refreshBalances(src)
|
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
|
||||||
} else if (!ok) {
|
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialogBuilder = AlertDialog.Builder(requireContext())
|
val dialogBuilder = MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(R.string.transfer)
|
.setTitle(R.string.transfer)
|
||||||
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
|
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
@@ -677,7 +769,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun doMibTransfer(
|
private fun doMibTransfer(
|
||||||
src: MibAccount,
|
src: BankAccount,
|
||||||
destAccount: String,
|
destAccount: String,
|
||||||
destName: String,
|
destName: String,
|
||||||
destDisplay: String,
|
destDisplay: String,
|
||||||
@@ -687,12 +779,14 @@ class TransferFragment : Fragment() {
|
|||||||
): Triple<Boolean, String, TransferReceiptData?> {
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
// Switch to the profile that owns the source account
|
// Switch to the profile that owns the source account
|
||||||
if (src.profileId.isNotBlank()) {
|
if (src.profileId.isNotBlank()) {
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId }
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
|
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
}
|
}
|
||||||
val otp = CredentialStore(requireContext()).loadMibCredentials()?.otpSeed
|
val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed
|
||||||
?.let { Totp.generate(it) }
|
?.let { Totp.generate(it) }
|
||||||
?: return Triple(false, "OTP unavailable", null)
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||||
@@ -721,7 +815,7 @@ class TransferFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
isMib = true,
|
bank = "MIB",
|
||||||
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||||
currency = currency,
|
currency = currency,
|
||||||
fromLabel = src.accountBriefName,
|
fromLabel = src.accountBriefName,
|
||||||
@@ -734,7 +828,7 @@ class TransferFragment : Fragment() {
|
|||||||
mibReferenceNo = result.trxId,
|
mibReferenceNo = result.trxId,
|
||||||
mibTransactionDate = result.date
|
mibTransactionDate = result.date
|
||||||
)
|
)
|
||||||
Triple(true, "Transaction ID: ${result.trxId}\n${result.date}", receipt)
|
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||||
} else {
|
} else {
|
||||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||||
}
|
}
|
||||||
@@ -744,7 +838,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun doBmlTransfer(
|
private fun doBmlTransfer(
|
||||||
src: MibAccount,
|
src: BankAccount,
|
||||||
destAccount: String,
|
destAccount: String,
|
||||||
destDisplay: String,
|
destDisplay: String,
|
||||||
amount: Double,
|
amount: Double,
|
||||||
@@ -753,8 +847,8 @@ class TransferFragment : Fragment() {
|
|||||||
isSrcCard: Boolean,
|
isSrcCard: Boolean,
|
||||||
isDestMib: Boolean,
|
isDestMib: Boolean,
|
||||||
currency: String,
|
currency: String,
|
||||||
allAccounts: List<MibAccount>,
|
allAccounts: List<BankAccount>,
|
||||||
allContacts: List<MibBeneficiary>
|
allContacts: List<BankContact>
|
||||||
): Triple<Boolean, String, TransferReceiptData?> {
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
val loginId = src.loginTag.removePrefix("bml_")
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
|
||||||
@@ -789,10 +883,9 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
val bmlFlow = BmlLoginFlow()
|
|
||||||
// Step 1: initiate
|
// Step 1: initiate
|
||||||
val initiated = try {
|
val initiated = try {
|
||||||
bmlFlow.initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||||
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
|
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) }
|
||||||
|
|
||||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||||
@@ -802,10 +895,10 @@ class TransferFragment : Fragment() {
|
|||||||
?.let { Totp.generate(it) } ?: otp
|
?.let { Totp.generate(it) } ?: otp
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val result = bmlFlow.confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
isMib = false,
|
bank = "BML",
|
||||||
amount = "%.2f".format(amount),
|
amount = "%.2f".format(amount),
|
||||||
currency = currency,
|
currency = currency,
|
||||||
fromLabel = src.accountBriefName,
|
fromLabel = src.accountBriefName,
|
||||||
@@ -829,7 +922,303 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BML business profile OTP flow ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun isBusinessProfile(account: BankAccount): Boolean {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginId = account.loginTag.removePrefix("bml_")
|
||||||
|
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||||
|
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startBmlBusinessOtpFlow(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>,
|
||||||
|
toAvatar: Bitmap?
|
||||||
|
) {
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val isDestMyCard = allAccounts.any {
|
||||||
|
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount
|
||||||
|
}
|
||||||
|
val (transferType, creditAccount, bank) = when {
|
||||||
|
isSrcCard -> {
|
||||||
|
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||||
|
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null as String?)
|
||||||
|
}
|
||||||
|
isDestMyCard -> {
|
||||||
|
val card = allAccounts.first {
|
||||||
|
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount
|
||||||
|
}
|
||||||
|
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
|
||||||
|
}
|
||||||
|
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||||
|
isDestMib -> {
|
||||||
|
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||||
|
if (contact == null) {
|
||||||
|
Toast.makeText(requireContext(), "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Triple("DOT", contact.benefNo.removePrefix("bml_"), null as String?)
|
||||||
|
}
|
||||||
|
else -> Triple("IAT", destAccount, null as String?)
|
||||||
|
}
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
pendingBmlTransfer = PendingBmlTransfer(
|
||||||
|
src = src,
|
||||||
|
debitAccount = debitAccount,
|
||||||
|
creditAccount = creditAccount,
|
||||||
|
amount = amount,
|
||||||
|
amountStr = amountStr,
|
||||||
|
remarks = remarks,
|
||||||
|
transferType = transferType,
|
||||||
|
currency = currency,
|
||||||
|
bank = bank,
|
||||||
|
destDisplay = destDisplay,
|
||||||
|
destAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
toAvatar = toAvatar
|
||||||
|
)
|
||||||
|
|
||||||
|
bmlOtpState = BmlOtpState.SELECTING_CHANNEL
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val sess = bmlSessionFor(src)
|
||||||
|
val channels = if (sess != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||||
|
catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
} else emptyList<BmlOtpChannel>()
|
||||||
|
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (channels.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||||
|
resetBmlOtpState()
|
||||||
|
updateTransferButton()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
showBmlChannelSelection(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBmlChannelSelection(channels: List<BmlOtpChannel>) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
binding.containerBmlChannels.removeAllViews()
|
||||||
|
|
||||||
|
for (channel in channels) {
|
||||||
|
val iconRes = when (channel.channel) {
|
||||||
|
"email" -> R.drawable.ic_channel_email
|
||||||
|
"mobile" -> R.drawable.ic_channel_sms
|
||||||
|
else -> R.drawable.ic_channel_sms
|
||||||
|
}
|
||||||
|
val iconSize = (24 * dp).toInt()
|
||||||
|
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||||
|
marginStart = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.description
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.masked
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
|
||||||
|
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 = (16 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||||
|
setPadding(hp, vp, hp, vp)
|
||||||
|
}
|
||||||
|
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
|
||||||
|
LinearLayout.LayoutParams(iconSize, iconSize))
|
||||||
|
row.addView(textCol)
|
||||||
|
row.setOnClickListener { selectBmlOtpChannel(channel) }
|
||||||
|
binding.containerBmlChannels.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
disableTransferFields()
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectBmlOtpChannel(channel: BmlOtpChannel) {
|
||||||
|
bmlOtpChannel = channel.channel
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
|
||||||
|
val pending = pendingBmlTransfer ?: return
|
||||||
|
val sess = bmlSessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
resetBmlOtpState()
|
||||||
|
updateTransferButton()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val initiated = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
BmlTransferClient().initiateTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
pending.bank, channel.channel
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (!initiated) {
|
||||||
|
Toast.makeText(requireContext(), "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||||
|
resetBmlOtpState()
|
||||||
|
updateTransferButton()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
bmlOtpState = BmlOtpState.AWAITING_OTP
|
||||||
|
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
|
||||||
|
binding.tilBmlOtp.visibility = View.VISIBLE
|
||||||
|
binding.etBmlOtp.requestFocus()
|
||||||
|
binding.btnTransfer.text = getString(R.string.transfer_verify_payment)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun verifyBmlOtp() {
|
||||||
|
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||||
|
if (otp.isEmpty()) {
|
||||||
|
binding.tilBmlOtp.error = "Enter the verification code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
val pending = pendingBmlTransfer ?: return
|
||||||
|
val channel = bmlOtpChannel ?: return
|
||||||
|
val sess = bmlSessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
val capturedToAvatar = pending.toAvatar
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
otp, pending.remarks, pending.bank, channel
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val r = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(pending.amount),
|
||||||
|
currency = pending.currency,
|
||||||
|
fromLabel = pending.src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
|
||||||
|
toAccount = pending.destAccount,
|
||||||
|
toBank = pending.toBank,
|
||||||
|
remarks = pending.remarks,
|
||||||
|
bmlFromName = pending.src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
Triple(true, "", r)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, e.message ?: "Transfer failed", null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
|
resetBmlOtpState()
|
||||||
|
clearForm()
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
activity.triggerRefresh()
|
||||||
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
binding.tilBmlOtp.error = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.cardFromInfo.alpha = 0.5f
|
||||||
|
binding.btnClearFromInfo.isEnabled = false
|
||||||
|
binding.cardToInfo.alpha = 0.5f
|
||||||
|
binding.btnClearToInfo.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.cardFromInfo.alpha = 1f
|
||||||
|
binding.btnClearFromInfo.isEnabled = true
|
||||||
|
binding.cardToInfo.alpha = 1f
|
||||||
|
binding.btnClearToInfo.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetBmlOtpState() {
|
||||||
|
bmlOtpState = BmlOtpState.NONE
|
||||||
|
bmlOtpChannel = null
|
||||||
|
pendingBmlTransfer = null
|
||||||
|
val b = _binding ?: return
|
||||||
|
b.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
b.tvBmlOtpSentVia.visibility = View.GONE
|
||||||
|
b.tilBmlOtp.visibility = View.GONE
|
||||||
|
b.etBmlOtp.setText("")
|
||||||
|
b.tilBmlOtp.error = null
|
||||||
|
enableTransferFields()
|
||||||
|
b.btnTransfer.text = getString(R.string.transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateTransferButton() {
|
||||||
|
if (bmlOtpState != BmlOtpState.NONE) return
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
|
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearForm() {
|
private fun clearForm() {
|
||||||
|
resetBmlOtpState()
|
||||||
selectedAccount = null
|
selectedAccount = null
|
||||||
binding.actvFrom.setText("", false)
|
binding.actvFrom.setText("", false)
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
@@ -859,7 +1248,7 @@ class TransferFragment : Fragment() {
|
|||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = if (isProfile) {
|
val base64 = if (isProfile) {
|
||||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||||
} else {
|
} else {
|
||||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
@@ -939,16 +1328,16 @@ class TransferFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun MibAccount.toDisplayString() = "$accountBriefName · $accountNumber"
|
private fun BankAccount.toDisplayString() = "$accountBriefName · $accountNumber"
|
||||||
|
|
||||||
// items: String = section header, MibAccount = selectable row
|
// items: String = section header, BankAccount = selectable row
|
||||||
private inner class AccountDropdownAdapter(
|
private inner class AccountDropdownAdapter(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
accounts: List<MibAccount>
|
accounts: List<BankAccount>
|
||||||
) : BaseAdapter(), Filterable {
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
private val items: List<Any> = buildList {
|
private val items: List<Any> = buildList {
|
||||||
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" }
|
||||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||||
addAll(regular)
|
addAll(regular)
|
||||||
if (cards.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
@@ -957,7 +1346,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
|
fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount)
|
||||||
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
|
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
|
|
||||||
override fun getCount() = items.size
|
override fun getCount() = items.size
|
||||||
@@ -982,7 +1371,7 @@ class TransferFragment : Fragment() {
|
|||||||
b.tvHeader.text = item
|
b.tvHeader.text = item
|
||||||
b.root
|
b.root
|
||||||
} else {
|
} else {
|
||||||
val acc = item as MibAccount
|
val acc = item as BankAccount
|
||||||
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||||
convertView.tag as ItemAccountDropdownBinding
|
convertView.tag as ItemAccountDropdownBinding
|
||||||
} else {
|
} else {
|
||||||
@@ -990,9 +1379,11 @@ class TransferFragment : Fragment() {
|
|||||||
.also { it.root.tag = it }
|
.also { it.root.tag = it }
|
||||||
}
|
}
|
||||||
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
||||||
b.tvDropdownAccountName.text = acc.accountBriefName
|
val isBmlAccount = acc.bank == "BML"
|
||||||
|
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
|
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||||
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
|
||||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: ""
|
||||||
b.root.alpha = if (inactive) 0.4f else 1f
|
b.root.alpha = if (inactive) 0.4f else 1f
|
||||||
b.root
|
b.root
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.util.Base64
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -23,12 +25,12 @@ 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.BmlHistoryClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
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.models.BankTransaction
|
||||||
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 sh.sar.basedbank.util.ContactImageCache
|
||||||
@@ -46,12 +48,12 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
private lateinit var adapter: TransactionAdapter
|
private lateinit var adapter: TransactionAdapter
|
||||||
|
|
||||||
// All merged transactions sorted by date desc
|
// All merged transactions sorted by date desc
|
||||||
private val allTransactions = mutableListOf<Transaction>()
|
private val allTransactions = mutableListOf<BankTransaction>()
|
||||||
private var searchQuery = ""
|
private var searchQuery = ""
|
||||||
|
|
||||||
// Per-account pagination state
|
// Per-account pagination state
|
||||||
private data class AccountState(
|
private data class AccountState(
|
||||||
val account: MibAccount,
|
val account: BankAccount,
|
||||||
var mibNextStart: Int = 1,
|
var mibNextStart: Int = 1,
|
||||||
var mibTotalCount: Int = -1,
|
var mibTotalCount: Int = -1,
|
||||||
var bmlNextPage: Int = 1,
|
var bmlNextPage: Int = 1,
|
||||||
@@ -61,10 +63,10 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
var fahipayTotal: Int = -1
|
var fahipayTotal: Int = -1
|
||||||
) {
|
) {
|
||||||
fun hasMore(): Boolean = when {
|
fun hasMore(): Boolean = when {
|
||||||
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
||||||
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +86,20 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
adapter = TransactionAdapter()
|
adapter = TransactionAdapter()
|
||||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||||
if (dy <= 0 || isLoading) return
|
if (dy <= 0 || isLoading) return
|
||||||
@@ -125,6 +138,14 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPages()
|
loadNextPages()
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
if (isLoading) {
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
} else {
|
||||||
|
resetAndReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
@@ -132,6 +153,19 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
requireActivity().title = getString(R.string.nav_transfer_history)
|
requireActivity().title = getString(R.string.nav_transfer_history)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resetAndReload() {
|
||||||
|
allTransactions.clear()
|
||||||
|
pendingImageNames.clear()
|
||||||
|
pendingIconUrls.clear()
|
||||||
|
firstBatchDone = false
|
||||||
|
val accounts = accountStates.map { it.account }
|
||||||
|
accountStates.clear()
|
||||||
|
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||||
|
adapter.setTransactions(emptyList())
|
||||||
|
binding.emptyView.visibility = View.GONE
|
||||||
|
loadNextPages()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadNextPages() {
|
private fun loadNextPages() {
|
||||||
val activeStates = accountStates.filter { it.hasMore() }
|
val activeStates = accountStates.filter { it.hasMore() }
|
||||||
if (isLoading || activeStates.isEmpty()) return
|
if (isLoading || activeStates.isEmpty()) return
|
||||||
@@ -142,14 +176,13 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val mibSession = app.mibSession
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val newTransactions = withContext(Dispatchers.IO) {
|
val newTransactions = withContext(Dispatchers.IO) {
|
||||||
val results = mutableListOf<Transaction>()
|
val results = mutableListOf<BankTransaction>()
|
||||||
|
|
||||||
// BML accounts: fetch in parallel
|
// BML accounts: fetch in parallel
|
||||||
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
|
val bmlStates = activeStates.filter { it.account.bank == "BML" }
|
||||||
results.addAll(bmlStates.map { state ->
|
results.addAll(bmlStates.map { state ->
|
||||||
async {
|
async {
|
||||||
try {
|
try {
|
||||||
@@ -160,7 +193,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||||
state.cardMonthOffset++
|
state.cardMonthOffset++
|
||||||
BmlLoginFlow().fetchCardHistory(
|
BmlHistoryClient().fetchCardHistory(
|
||||||
session = session,
|
session = session,
|
||||||
cardId = state.account.internalId,
|
cardId = state.account.internalId,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
@@ -170,7 +203,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
|
||||||
session = session,
|
session = session,
|
||||||
accountId = state.account.internalId,
|
accountId = state.account.internalId,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
@@ -182,18 +215,16 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
list
|
list
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList<Transaction>() }
|
} catch (_: Exception) { emptyList<BankTransaction>() }
|
||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.awaitAll().flatten())
|
||||||
|
|
||||||
// Fahipay accounts
|
// Fahipay accounts
|
||||||
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" }
|
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||||
for (state in fahipayStates) {
|
for (state in fahipayStates) {
|
||||||
val session = app.fahipaySession ?: continue
|
val session = app.fahipaySessionFor(state.account) ?: continue
|
||||||
try {
|
try {
|
||||||
val flow = FahipayLoginFlow()
|
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||||
flow.setSessionCookie(session.sessionCookie)
|
|
||||||
val (list, total) = flow.fetchHistory(
|
|
||||||
session = session,
|
session = session,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
accountNumber = state.account.accountNumber,
|
accountNumber = state.account.accountNumber,
|
||||||
@@ -206,27 +237,28 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||||
val mibStates = activeStates.filter {
|
val mibStates = activeStates.filter { it.account.bank == "MIB" }
|
||||||
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY"
|
for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) {
|
||||||
}
|
val session = app.mibSessions[loginId] ?: continue
|
||||||
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
|
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
||||||
val session = mibSession ?: break
|
app.mibMutex.withLock {
|
||||||
app.mibMutex.withLock {
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
|
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||||
for (state in states) {
|
for (state in states) {
|
||||||
try {
|
try {
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
session = session,
|
session = session,
|
||||||
accountNo = state.account.accountNumber,
|
accountNo = state.account.accountNumber,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
start = state.mibNextStart,
|
start = state.mibNextStart,
|
||||||
pageSize = pageSize
|
pageSize = pageSize
|
||||||
)
|
)
|
||||||
if (total > 0) state.mibTotalCount = total
|
if (total > 0) state.mibTotalCount = total
|
||||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
results.addAll(list)
|
results.addAll(list)
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -239,6 +271,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
if (!firstBatchDone) {
|
if (!firstBatchDone) {
|
||||||
firstBatchDone = true
|
firstBatchDone = true
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newTransactions.isNotEmpty()) {
|
if (newTransactions.isNotEmpty()) {
|
||||||
@@ -280,7 +313,7 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.mibSession ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
data class TransferReceiptData(
|
data class TransferReceiptData(
|
||||||
val isMib: Boolean,
|
val bank: String, // "MIB", "BML", etc.
|
||||||
val amount: String,
|
val amount: String,
|
||||||
val currency: String,
|
val currency: String,
|
||||||
val fromLabel: String,
|
val fromLabel: String,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private val receiptCard get() = _receiptCard!!
|
private val receiptCard get() = _receiptCard!!
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARG_IS_MIB = "is_mib"
|
private const val ARG_BANK = "bank"
|
||||||
private const val ARG_AMOUNT = "amount"
|
private const val ARG_AMOUNT = "amount"
|
||||||
private const val ARG_CURRENCY = "currency"
|
private const val ARG_CURRENCY = "currency"
|
||||||
private const val ARG_FROM_LABEL = "from_label"
|
private const val ARG_FROM_LABEL = "from_label"
|
||||||
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
|
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
|
||||||
pendingToAvatarBitmap = toAvatarBitmap
|
pendingToAvatarBitmap = toAvatarBitmap
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putBoolean(ARG_IS_MIB, data.isMib)
|
putString(ARG_BANK, data.bank)
|
||||||
putString(ARG_AMOUNT, data.amount)
|
putString(ARG_AMOUNT, data.amount)
|
||||||
putString(ARG_CURRENCY, data.currency)
|
putString(ARG_CURRENCY, data.currency)
|
||||||
putString(ARG_FROM_LABEL, data.fromLabel)
|
putString(ARG_FROM_LABEL, data.fromLabel)
|
||||||
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
return if (isMib) {
|
return if (bank == "MIB") {
|
||||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||||
bindMib(binding)
|
bindMib(binding)
|
||||||
_receiptCard = binding.receiptCard
|
_receiptCard = binding.receiptCard
|
||||||
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||||
parentFragmentManager.popBackStack()
|
parentFragmentManager.popBackStack()
|
||||||
}
|
}
|
||||||
@@ -150,15 +156,21 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
||||||
|
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
||||||
|
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.mibSession ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = if (isProfile) {
|
val base64 = if (isProfile) {
|
||||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||||
} else {
|
} else {
|
||||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
binding.remarksDivider.visibility = View.VISIBLE
|
binding.remarksDivider.visibility = View.VISIBLE
|
||||||
binding.remarksRow.visibility = View.VISIBLE
|
binding.remarksRow.visibility = View.VISIBLE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
|
||||||
|
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
|
||||||
|
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
|
||||||
|
binding.tvAmountCurrency, binding.tvRemarks
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||||
@@ -310,9 +329,75 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showFullScreenReceipt() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
|
||||||
|
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||||
|
setBackgroundColor(Color.BLACK)
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardView = if (bank == "MIB") {
|
||||||
|
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||||
|
bindMib(binding)
|
||||||
|
binding.receiptCard
|
||||||
|
} else {
|
||||||
|
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
||||||
|
bindBml(binding)
|
||||||
|
binding.receiptCard
|
||||||
|
}
|
||||||
|
(cardView.parent as? ViewGroup)?.removeView(cardView)
|
||||||
|
cardView.setOnClickListener { dialog.dismiss() }
|
||||||
|
scrollView.addView(cardView)
|
||||||
|
scrollView.setOnTouchListener { _, _ -> dialog.dismiss(); true }
|
||||||
|
|
||||||
|
dialog.setContentView(scrollView)
|
||||||
|
|
||||||
|
val actWin = requireActivity().window
|
||||||
|
val prevColor = actWin.statusBarColor
|
||||||
|
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
|
||||||
|
actWin.statusBarColor = Color.BLACK
|
||||||
|
insetsCtrl.isAppearanceLightStatusBars = false
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
actWin.statusBarColor = prevColor
|
||||||
|
val isLight = (resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
dialog.window?.let { win ->
|
||||||
|
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat(win, scrollView).apply {
|
||||||
|
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||||
|
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
|
for (tv in views) {
|
||||||
|
tv.setOnLongClickListener {
|
||||||
|
val text = tv.text?.toString()?.trim() ?: return@setOnLongClickListener false
|
||||||
|
if (text.isBlank()) return@setOnLongClickListener false
|
||||||
|
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
cm.setPrimaryClip(ClipData.newPlainText("receipt", text))
|
||||||
|
Toast.makeText(requireContext(), "Copied", Toast.LENGTH_SHORT).show()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = "Receipt"
|
requireActivity().title = "Receipt"
|
||||||
|
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|||||||
@@ -18,14 +18,20 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
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.BmlAccountClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
import sh.sar.basedbank.util.AccountCache
|
import sh.sar.basedbank.util.AccountCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
|
||||||
class CredentialsFragment : Fragment() {
|
class CredentialsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -46,6 +52,11 @@ class CredentialsFragment : Fragment() {
|
|||||||
private var fahipayFlow: FahipayLoginFlow? = null
|
private var fahipayFlow: FahipayLoginFlow? = null
|
||||||
private var fahipayAwaitingTotp = false
|
private var fahipayAwaitingTotp = false
|
||||||
|
|
||||||
|
// BML multi-profile state
|
||||||
|
private var bmlFlow: BmlLoginFlow? = null
|
||||||
|
private var bmlLoginId: String = ""
|
||||||
|
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -54,7 +65,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
when (bankType) {
|
when (bankType) {
|
||||||
"BML" -> {
|
"BML" -> {
|
||||||
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector)
|
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_long)
|
||||||
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
|
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
|
||||||
}
|
}
|
||||||
"FAHIPAY" -> {
|
"FAHIPAY" -> {
|
||||||
@@ -67,11 +78,27 @@ class CredentialsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||||
|
|
||||||
|
val loginFieldWatcher = object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
}
|
||||||
|
binding.etUsername.addTextChangedListener(loginFieldWatcher)
|
||||||
|
binding.etPassword.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) { updateLoginButtonState(); updateOtpDisplay() }
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
|
||||||
if (bankType != "FAHIPAY") {
|
if (bankType != "FAHIPAY") {
|
||||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() }
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
updateOtpDisplay()
|
||||||
|
updateLoginButtonState()
|
||||||
|
}
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
})
|
})
|
||||||
@@ -88,10 +115,35 @@ class CredentialsFragment : Fragment() {
|
|||||||
otpHandler.removeCallbacks(otpRunnable)
|
otpHandler.removeCallbacks(otpRunnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resolveOtpSeed(input: String): String {
|
||||||
|
val secret = if (input.startsWith("otpauth://totp/"))
|
||||||
|
android.net.Uri.parse(input).getQueryParameter("secret") ?: input
|
||||||
|
else
|
||||||
|
input
|
||||||
|
return secret.replace("\\s".toRegex(), "").replace("-", "").uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLoginButtonState() {
|
||||||
|
val username = binding.etUsername.text.toString().trim()
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
|
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||||
|
binding.btnLogin.isEnabled = when (bankType) {
|
||||||
|
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||||
|
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateOtpDisplay() {
|
private fun updateOtpDisplay() {
|
||||||
val seed = binding.etOtpSeed.text.toString().trim()
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
|
val seed = resolveOtpSeed(otpSeedRaw)
|
||||||
if (seed.isEmpty()) {
|
if (seed.isEmpty()) {
|
||||||
binding.cardOtp.visibility = View.GONE
|
binding.cardOtp.visibility = View.INVISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val password = binding.etPassword.text.toString()
|
||||||
|
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||||
|
binding.cardOtp.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -104,7 +156,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.otpTimer.progress = remaining
|
binding.otpTimer.progress = remaining
|
||||||
binding.cardOtp.visibility = View.VISIBLE
|
binding.cardOtp.visibility = View.VISIBLE
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.cardOtp.visibility = View.GONE
|
binding.cardOtp.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +168,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
|
|
||||||
val username = binding.etUsername.text.toString().trim()
|
val username = binding.etUsername.text.toString().trim()
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||||
|
|
||||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||||
binding.tvError.text = "Please fill in all fields"
|
binding.tvError.text = "Please fill in all fields"
|
||||||
@@ -129,6 +181,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
val passwordHash = MibLoginFlow.hashPassword(password)
|
val passwordHash = MibLoginFlow.hashPassword(password)
|
||||||
|
val loginId = username
|
||||||
val flow = MibLoginFlow(CredentialStore(requireContext()))
|
val flow = MibLoginFlow(CredentialStore(requireContext()))
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -137,11 +190,12 @@ class CredentialsFragment : Fragment() {
|
|||||||
flow.login(username, passwordHash, otpSeed)
|
flow.login(username, passwordHash, otpSeed)
|
||||||
}
|
}
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
store.saveMibCredentials(username, passwordHash, otpSeed)
|
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
flow.lastSession?.let { s ->
|
flow.lastSession?.let { s ->
|
||||||
val profile = flow.fetchPersonalProfile(s)
|
val profile = MibProfileClient().fetchPersonalProfile(s)
|
||||||
if (profile != null) store.saveMibUserProfile(
|
if (profile != null) store.saveMibUserProfile(
|
||||||
|
loginId,
|
||||||
CredentialStore.MibUserProfile(
|
CredentialStore.MibUserProfile(
|
||||||
fullName = profile.fullName,
|
fullName = profile.fullName,
|
||||||
username = profile.username,
|
username = profile.username,
|
||||||
@@ -152,11 +206,15 @@ class CredentialsFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AccountCache.save(requireContext(), accounts)
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.accounts = accounts
|
// Merge with any existing MIB accounts from other logins
|
||||||
app.mibSession = flow.lastSession
|
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts
|
||||||
app.mibProfiles = flow.lastProfiles
|
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } + accounts
|
||||||
|
AccountCache.save(requireContext(), app.mibAccounts)
|
||||||
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
|
app.mibLoginFlows[loginId] = flow
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
@@ -173,7 +231,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
private fun attemptBmlLogin() {
|
private fun attemptBmlLogin() {
|
||||||
val username = binding.etUsername.text.toString().trim()
|
val username = binding.etUsername.text.toString().trim()
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||||
|
|
||||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||||
binding.tvError.text = "Please fill in all fields"
|
binding.tvError.text = "Please fill in all fields"
|
||||||
@@ -185,20 +243,72 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.progressBar.visibility = View.VISIBLE
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
val loginId = username
|
bmlLoginId = username
|
||||||
val flow = BmlLoginFlow()
|
bmlAccumulatedAccounts.clear()
|
||||||
|
|
||||||
|
val flow = BmlLoginFlow().also { bmlFlow = it }
|
||||||
|
val loginTag = "bml_$username"
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
val (session, accounts) = withContext(Dispatchers.IO) {
|
val profiles = withContext(Dispatchers.IO) {
|
||||||
flow.login(username, password, otpSeed)
|
flow.login(username, password, otpSeed)
|
||||||
}
|
}
|
||||||
|
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
|
||||||
|
|
||||||
|
var hasBusinessProfiles = false
|
||||||
|
for (profile in profiles) {
|
||||||
|
if (profile.profileType == "business") {
|
||||||
|
hasBusinessProfiles = true
|
||||||
|
continue // skip — user can enable in Settings → Logins
|
||||||
|
}
|
||||||
|
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
|
||||||
|
if (result is BmlActivationResult.Success) {
|
||||||
|
bmlAccumulatedAccounts += result.accounts
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||||
|
if (result.session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, result.session.refreshToken)
|
||||||
|
if (result.session.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, result.session.expiresAt)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.bmlSessions[profile.profileId] = result.session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
store.saveBmlCredentials(loginId, username, password, otpSeed)
|
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
|
||||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
store.saveBmlProfiles(bmlLoginId, profiles)
|
||||||
withContext(Dispatchers.IO) {
|
val app = requireActivity().application as BasedBankApp
|
||||||
val info = flow.fetchUserInfo(session)
|
app.bmlProfilesMap[bmlLoginId] = profiles
|
||||||
if (info != null) store.saveBmlUserProfile(
|
app.bmlLoginFlows[bmlLoginId] = flow
|
||||||
loginId,
|
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
|
||||||
|
finishBmlLogin(hasBusinessProfiles)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "Login failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
val accounts = bmlAccumulatedAccounts.toList()
|
||||||
|
|
||||||
|
// Fetch user profile info from any active session
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val anySession = app.anyBmlSessionFor(bmlLoginId)
|
||||||
|
if (anySession != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val info = BmlAccountClient().fetchUserInfo(anySession)
|
||||||
|
if (info != null) {
|
||||||
|
store.saveBmlUserProfile(
|
||||||
|
bmlLoginId,
|
||||||
CredentialStore.BmlUserProfile(
|
CredentialStore.BmlUserProfile(
|
||||||
fullName = info.fullName,
|
fullName = info.fullName,
|
||||||
email = info.email,
|
email = info.email,
|
||||||
@@ -208,24 +318,55 @@ class CredentialsFragment : Fragment() {
|
|||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
// Single-profile accounts used username as a temporary profileId.
|
||||||
|
// Replace it with the real BML customer ID so multi-login doesn't collide.
|
||||||
|
val customerId = info.customerId
|
||||||
|
if (customerId.isNotBlank()) {
|
||||||
|
val profiles = store.loadBmlProfiles(bmlLoginId)
|
||||||
|
val autoProfile = profiles.firstOrNull { it.autoActivated }
|
||||||
|
if (autoProfile != null && autoProfile.profileId != customerId) {
|
||||||
|
val oldId = autoProfile.profileId
|
||||||
|
// Re-key session in memory and storage
|
||||||
|
val session = app.bmlSessions.remove(oldId)
|
||||||
|
if (session != null) {
|
||||||
|
app.bmlSessions[customerId] = session
|
||||||
|
val savedRefresh = store.loadBmlProfileRefreshToken(oldId)
|
||||||
|
val savedExpiry = store.loadBmlProfileExpiresAt(oldId)
|
||||||
|
store.clearBmlProfileSession(oldId)
|
||||||
|
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
|
||||||
|
if (session.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(customerId, session.refreshToken)
|
||||||
|
else if (savedRefresh != null)
|
||||||
|
store.saveBmlProfileRefreshToken(customerId, savedRefresh)
|
||||||
|
val expiryToSave = if (session.expiresAt > 0) session.expiresAt else savedExpiry
|
||||||
|
if (expiryToSave > 0) store.saveBmlProfileExpiresAt(customerId, expiryToSave)
|
||||||
|
}
|
||||||
|
// Update stored profile list with the real ID
|
||||||
|
val updatedProfiles = profiles.map {
|
||||||
|
if (it.autoActivated) it.copy(profileId = customerId) else it
|
||||||
|
}
|
||||||
|
store.saveBmlProfiles(bmlLoginId, updatedProfiles)
|
||||||
|
app.bmlProfilesMap[bmlLoginId] = updatedProfiles
|
||||||
|
// Update accounts to use real profileId
|
||||||
|
bmlAccumulatedAccounts.replaceAll { acc ->
|
||||||
|
if (acc.profileId == oldId) acc.copy(profileId = customerId) else acc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AccountCache.saveBml(requireContext(), loginId, accounts)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
app.bmlSessions[loginId] = session
|
|
||||||
// Merge with any existing BML accounts from other logins
|
|
||||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
|
|
||||||
app.accounts = app.accounts + accounts
|
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
binding.tvError.text = e.message ?: "Login failed"
|
|
||||||
binding.tvError.visibility = View.VISIBLE
|
|
||||||
} finally {
|
|
||||||
binding.progressBar.visibility = View.GONE
|
|
||||||
binding.btnLogin.isEnabled = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
|
||||||
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||||
|
|
||||||
|
if (hasBusinessProfiles) {
|
||||||
|
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attemptFahipayLogin() {
|
private fun attemptFahipayLogin() {
|
||||||
@@ -329,15 +470,17 @@ class CredentialsFragment : Fragment() {
|
|||||||
store: CredentialStore
|
store: CredentialStore
|
||||||
) {
|
) {
|
||||||
val (profile, balance) = withContext(Dispatchers.IO) {
|
val (profile, balance) = withContext(Dispatchers.IO) {
|
||||||
val p = flow.fetchProfile(session)
|
val p = FahipayAccountClient().fetchProfile(session)
|
||||||
val b = flow.fetchBalance(session)
|
val b = FahipayAccountClient().fetchBalance(session)
|
||||||
Pair(p, b)
|
Pair(p, b)
|
||||||
}
|
}
|
||||||
val loginTag = "fahipay_${profile.profileId}"
|
val loginId = profile.profileId
|
||||||
val account = flow.buildAccount(profile, balance, loginTag)
|
val loginTag = "fahipay_$loginId"
|
||||||
store.saveFahipayCredentials(idCard, password)
|
val account = FahipayAccountClient().buildAccount(profile, balance, loginTag)
|
||||||
store.saveFahipaySession(session.authId, session.sessionCookie)
|
store.saveFahipayCredentials(loginId, idCard, password)
|
||||||
|
store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
|
||||||
store.saveFahipayUserProfile(
|
store.saveFahipayUserProfile(
|
||||||
|
loginId,
|
||||||
CredentialStore.FahipayUserProfile(
|
CredentialStore.FahipayUserProfile(
|
||||||
fullName = profile.fullName,
|
fullName = profile.fullName,
|
||||||
email = profile.email,
|
email = profile.email,
|
||||||
@@ -348,11 +491,11 @@ class CredentialsFragment : Fragment() {
|
|||||||
linkedAccounts = profile.linkedAccounts
|
linkedAccounts = profile.linkedAccounts
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
AccountCache.saveFahipay(requireContext(), listOf(account))
|
AccountCache.saveFahipay(requireContext(), loginId, listOf(account))
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
app.fahipaySession = session
|
app.fahipaySessions[loginId] = session
|
||||||
app.fahipayAccounts = listOf(account)
|
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||||
app.accounts = app.accounts + listOf(account)
|
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
||||||
|
|
||||||
class LoginActivity : AppCompatActivity() {
|
class LoginActivity : AppCompatActivity() {
|
||||||
@@ -10,7 +12,13 @@ class LoginActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package sh.sar.basedbank.ui.onboarding
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.CountDownTimer
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
import com.google.android.material.tabs.TabLayoutMediator
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -17,46 +21,73 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
|
|
||||||
private lateinit var binding: ActivityOnboardingBinding
|
private lateinit var binding: ActivityOnboardingBinding
|
||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
|
private var countDownTimer: CountDownTimer? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
binding = ActivityOnboardingBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { view, insets ->
|
||||||
|
val statusBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
view.setPadding(view.paddingLeft, statusBar.top, view.paddingRight, view.paddingBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
val adapter = OnboardingPagerAdapter(this)
|
val adapter = OnboardingPagerAdapter(this)
|
||||||
binding.viewPager.adapter = adapter
|
binding.viewPager.adapter = adapter
|
||||||
|
|
||||||
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
|
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
|
||||||
|
// Disable tap-to-navigate on dots: touch listener must be on the individual
|
||||||
// Pre-select language chip without triggering the listener
|
// tab views inside SlidingTabStrip (child 0), because they consume ACTION_DOWN
|
||||||
val savedLang = prefs.getString("language", null)
|
// before the TabLayout's own touch listener ever fires.
|
||||||
binding.languageChipGroup.setOnCheckedStateChangeListener(null)
|
val tabStrip = binding.dotsIndicator.getChildAt(0) as? android.view.ViewGroup
|
||||||
when (savedLang) {
|
tabStrip?.let {
|
||||||
"en" -> binding.chipEnglish.isChecked = true
|
for (i in 0 until it.childCount) {
|
||||||
"dv" -> binding.chipDhivehi.isChecked = true
|
it.getChildAt(i).setOnTouchListener { _, _ -> true }
|
||||||
}
|
|
||||||
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
|
|
||||||
if (checkedIds.isNotEmpty()) {
|
|
||||||
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pre-select language button without triggering the listener
|
||||||
|
val savedLang = prefs.getString("language", null) ?: "en".also { selectLanguage(it) }
|
||||||
|
binding.languageToggle.clearOnButtonCheckedListeners()
|
||||||
|
when (savedLang) {
|
||||||
|
"en" -> binding.btnLangEnglish.isChecked = true
|
||||||
|
"dv" -> binding.btnLangDhivehi.isChecked = true
|
||||||
|
}
|
||||||
|
binding.languageToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (isChecked) selectLanguage(if (checkedId == R.id.btnLangEnglish) "en" else "dv")
|
||||||
|
}
|
||||||
|
|
||||||
|
supportFragmentManager.setFragmentResultListener(OnboardingFragment.RESULT_SCROLLED_TO_BOTTOM, this) { _, _ ->
|
||||||
|
startGetStartedCountdown()
|
||||||
|
}
|
||||||
|
|
||||||
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
|
||||||
override fun onPageSelected(position: Int) {
|
override fun onPageSelected(position: Int) {
|
||||||
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE
|
binding.languageSection.visibility = if (position == 0) View.VISIBLE else View.GONE
|
||||||
// Block forward swipe on slide 1 until security is set up
|
binding.viewPager.isUserInputEnabled = when {
|
||||||
if (position == 1) {
|
position > 2 -> false
|
||||||
binding.viewPager.isUserInputEnabled =
|
position == 1 -> prefs.getString("security_method", null) != null
|
||||||
prefs.getString("security_method", null) != null
|
else -> true
|
||||||
} else {
|
|
||||||
binding.viewPager.isUserInputEnabled = true
|
|
||||||
}
|
}
|
||||||
updateButtons(position, adapter.itemCount)
|
updateButtons(position, adapter.itemCount)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
binding.languageChipGroup.visibility = View.VISIBLE
|
binding.languageSection.visibility = View.VISIBLE
|
||||||
updateButtons(0, adapter.itemCount)
|
updateButtons(0, adapter.itemCount)
|
||||||
|
|
||||||
binding.btnNext.setOnClickListener {
|
binding.btnNext.setOnClickListener {
|
||||||
@@ -71,10 +102,21 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
countDownTimer?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
// Called by SecuritySetupFragment when setup is complete
|
// Called by SecuritySetupFragment when setup is complete
|
||||||
override fun onSecuritySetupComplete() {
|
override fun onSecuritySetupComplete() {
|
||||||
binding.viewPager.isUserInputEnabled = true
|
binding.viewPager.isUserInputEnabled = true
|
||||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by SecuritySetupFragment when user resets to reconfigure
|
||||||
|
override fun onSecuritySetupReset() {
|
||||||
|
binding.viewPager.isUserInputEnabled = false
|
||||||
|
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectLanguage(lang: String) {
|
private fun selectLanguage(lang: String) {
|
||||||
@@ -83,23 +125,34 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startGetStartedCountdown() {
|
||||||
|
binding.btnGetStarted.isEnabled = false
|
||||||
|
countDownTimer?.cancel()
|
||||||
|
countDownTimer = object : CountDownTimer(5000, 1000) {
|
||||||
|
override fun onTick(millisUntilFinished: Long) {
|
||||||
|
val seconds = (millisUntilFinished / 1000 + 1).toInt()
|
||||||
|
binding.btnGetStarted.text = "${getString(R.string.get_started)} ($seconds)"
|
||||||
|
}
|
||||||
|
override fun onFinish() {
|
||||||
|
binding.btnGetStarted.text = getString(R.string.get_started)
|
||||||
|
binding.btnGetStarted.isEnabled = true
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateButtons(position: Int, count: Int) {
|
private fun updateButtons(position: Int, count: Int) {
|
||||||
val langSelected = prefs.getString("language", null) != null
|
val langSelected = prefs.getString("language", null) != null
|
||||||
val securityDone = prefs.getString("security_method", null) != null
|
val securityDone = prefs.getString("security_method", null) != null
|
||||||
val isLast = position == count - 1
|
val isLast = position == count - 1
|
||||||
|
|
||||||
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
|
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
|
||||||
|
if (isLast) binding.btnGetStarted.isEnabled = false
|
||||||
|
|
||||||
// Hide Next on slide 1 until security is done (avoids a disabled-button-with-no-explanation)
|
binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
|
||||||
binding.btnNext.visibility = when {
|
|
||||||
isLast -> View.GONE
|
|
||||||
position == 1 && !securityDone -> View.GONE
|
|
||||||
else -> View.VISIBLE
|
|
||||||
}
|
|
||||||
binding.btnNext.isEnabled = when (position) {
|
binding.btnNext.isEnabled = when (position) {
|
||||||
0 -> langSelected
|
0 -> langSelected
|
||||||
1 -> securityDone
|
1 -> securityDone
|
||||||
else -> true
|
else -> true // position 2 (configure) has no gate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package sh.sar.basedbank.ui.onboarding
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||||
|
|
||||||
|
class OnboardingConfigureFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentOnboardingConfigureBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentOnboardingConfigureBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// Navigation — default Drawer
|
||||||
|
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||||
|
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||||
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme — default System
|
||||||
|
val savedTheme = prefs.getString("theme", "system")
|
||||||
|
binding.themeToggle.check(when (savedTheme) {
|
||||||
|
"light" -> R.id.btnThemeLight
|
||||||
|
"dark" -> R.id.btnThemeDark
|
||||||
|
else -> R.id.btnThemeSystem
|
||||||
|
})
|
||||||
|
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val (key, mode) = when (checkedId) {
|
||||||
|
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
prefs.edit().putString("theme", key).apply()
|
||||||
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Biometrics
|
||||||
|
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
if (canUseBiometrics) {
|
||||||
|
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
binding.switchBiometrics.isChecked = unlockEnabled
|
||||||
|
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
|
||||||
|
|
||||||
|
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = isChecked
|
||||||
|
if (!isChecked) {
|
||||||
|
binding.switchBiometricsTransfer.isChecked = false
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||||
|
binding.switchBiometrics.isEnabled = false
|
||||||
|
binding.switchBiometricsTransfer.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block screenshots — default on
|
||||||
|
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||||
|
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||||
|
applyFlagSecure(blockScreenshots)
|
||||||
|
binding.switchBlockScreenshots.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
prefs.edit().putBoolean("block_screenshots", isChecked).apply()
|
||||||
|
applyFlagSecure(isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFlagSecure(enabled: Boolean) {
|
||||||
|
val win = activity?.window ?: return
|
||||||
|
if (enabled) win.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
else win.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.ViewTreeObserver
|
||||||
|
import android.widget.ScrollView
|
||||||
import androidx.core.os.bundleOf
|
import androidx.core.os.bundleOf
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
|
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
|
||||||
@@ -12,6 +14,7 @@ class OnboardingFragment : Fragment() {
|
|||||||
|
|
||||||
private var _binding: FragmentOnboardingSlideBinding? = null
|
private var _binding: FragmentOnboardingSlideBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
private var scrolledToBottom = false
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
|
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
|
||||||
@@ -19,17 +22,45 @@ class OnboardingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val title = requireArguments().getString(ARG_TITLE, "")
|
val titleRes = requireArguments().getInt(ARG_TITLE)
|
||||||
val desc = requireArguments().getString(ARG_DESC, "")
|
val descRes = requireArguments().getInt(ARG_DESC)
|
||||||
val icon = requireArguments().getInt(ARG_ICON, 0)
|
val icon = requireArguments().getInt(ARG_ICON, 0)
|
||||||
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
|
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
|
||||||
|
val isLast = requireArguments().getBoolean(ARG_IS_LAST, false)
|
||||||
|
|
||||||
|
binding.icon.visibility = if (isLast) View.GONE else View.VISIBLE
|
||||||
binding.icon.setImageResource(icon)
|
binding.icon.setImageResource(icon)
|
||||||
binding.title.text = title
|
binding.title.text = getString(titleRes)
|
||||||
binding.description.text = desc
|
binding.description.text = getString(descRes)
|
||||||
|
binding.description.gravity = if (isLast) android.view.Gravity.START else android.view.Gravity.CENTER
|
||||||
|
|
||||||
// On the first slide, show the two placeholder cards for upcoming banks
|
// On the first slide, show the two placeholder cards for upcoming banks
|
||||||
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
|
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
if (isLast) setupScrollToBottomDetection()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupScrollToBottomDetection() {
|
||||||
|
val scrollView = binding.scrollView
|
||||||
|
// If content fits without scrolling, fire immediately after layout
|
||||||
|
scrollView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
scrollView.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
val child = scrollView.getChildAt(0) ?: return
|
||||||
|
if (child.height <= scrollView.height) notifyScrolledToBottom()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
scrollView.setOnScrollChangeListener { v, _, scrollY, _, _ ->
|
||||||
|
val sv = v as ScrollView
|
||||||
|
val child = sv.getChildAt(0) ?: return@setOnScrollChangeListener
|
||||||
|
if (scrollY + sv.height >= child.height) notifyScrolledToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyScrolledToBottom() {
|
||||||
|
if (scrolledToBottom) return
|
||||||
|
scrolledToBottom = true
|
||||||
|
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
@@ -38,17 +69,20 @@ class OnboardingFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val RESULT_SCROLLED_TO_BOTTOM = "scroll_to_bottom"
|
||||||
private const val ARG_TITLE = "title"
|
private const val ARG_TITLE = "title"
|
||||||
private const val ARG_DESC = "desc"
|
private const val ARG_DESC = "desc"
|
||||||
private const val ARG_ICON = "icon"
|
private const val ARG_ICON = "icon"
|
||||||
private const val ARG_IS_FIRST = "is_first"
|
private const val ARG_IS_FIRST = "is_first"
|
||||||
|
private const val ARG_IS_LAST = "is_last"
|
||||||
|
|
||||||
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
|
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
|
||||||
arguments = bundleOf(
|
arguments = bundleOf(
|
||||||
ARG_TITLE to slide.title,
|
ARG_TITLE to slide.titleRes,
|
||||||
ARG_DESC to slide.description,
|
ARG_DESC to slide.descRes,
|
||||||
ARG_ICON to slide.iconRes,
|
ARG_ICON to slide.iconRes,
|
||||||
ARG_IS_FIRST to slide.isFirst
|
ARG_IS_FIRST to slide.isFirst,
|
||||||
|
ARG_IS_LAST to slide.isLast
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,36 +9,39 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
|
|||||||
|
|
||||||
private val slides = listOf(
|
private val slides = listOf(
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
title = activity.getString(R.string.onboarding_title_1),
|
titleRes = R.string.onboarding_title_1,
|
||||||
description = activity.getString(R.string.onboarding_desc_1),
|
descRes = R.string.onboarding_desc_1,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_logo,
|
||||||
isFirst = true
|
isFirst = true
|
||||||
),
|
),
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
title = activity.getString(R.string.onboarding_title_2),
|
titleRes = R.string.onboarding_title_2,
|
||||||
description = activity.getString(R.string.onboarding_desc_2),
|
descRes = R.string.onboarding_desc_2,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_logo,
|
||||||
isFirst = false
|
isFirst = false
|
||||||
),
|
),
|
||||||
OnboardingSlide(
|
OnboardingSlide(
|
||||||
title = activity.getString(R.string.onboarding_title_3),
|
titleRes = R.string.onboarding_title_3,
|
||||||
description = activity.getString(R.string.onboarding_desc_3),
|
descRes = R.string.onboarding_desc_3,
|
||||||
iconRes = R.drawable.ic_launcher_foreground,
|
iconRes = R.drawable.ic_logo,
|
||||||
isFirst = false
|
isFirst = false,
|
||||||
|
isLast = true
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun getItemCount() = slides.size
|
override fun getItemCount() = slides.size + 1 // +1 for OnboardingConfigureFragment at position 2
|
||||||
|
|
||||||
override fun createFragment(position: Int): Fragment = when (position) {
|
override fun createFragment(position: Int): Fragment = when (position) {
|
||||||
1 -> SecuritySetupFragment()
|
1 -> SecuritySetupFragment()
|
||||||
else -> OnboardingFragment.newInstance(slides[position])
|
2 -> OnboardingConfigureFragment()
|
||||||
|
else -> OnboardingFragment.newInstance(slides[position - if (position > 2) 1 else 0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class OnboardingSlide(
|
data class OnboardingSlide(
|
||||||
val title: String,
|
val titleRes: Int,
|
||||||
val description: String,
|
val descRes: Int,
|
||||||
val iconRes: Int,
|
val iconRes: Int,
|
||||||
val isFirst: Boolean
|
val isFirst: Boolean,
|
||||||
|
val isLast: Boolean = false
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,17 +86,21 @@ class PatternView @JvmOverloads constructor(
|
|||||||
if (errorState) return false
|
if (errorState) return false
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
MotionEvent.ACTION_DOWN -> {
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(true)
|
||||||
recording = true; selected.clear()
|
recording = true; selected.clear()
|
||||||
hit(event.x, event.y)
|
hit(event.x, event.y)
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(true)
|
||||||
touchX = event.x; touchY = event.y
|
touchX = event.x; touchY = event.y
|
||||||
hit(event.x, event.y)
|
hit(event.x, event.y)
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_UP -> {
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
parent?.requestDisallowInterceptTouchEvent(false)
|
||||||
recording = false
|
recording = false
|
||||||
invalidate()
|
invalidate()
|
||||||
onPatternComplete?.invoke(selected.map { it.index })
|
if (event.action == MotionEvent.ACTION_UP)
|
||||||
|
onPatternComplete?.invoke(selected.map { it.index })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
invalidate()
|
invalidate()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.biometric.BiometricManager
|
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -21,6 +20,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
|
|
||||||
interface Callback {
|
interface Callback {
|
||||||
fun onSecuritySetupComplete()
|
fun onSecuritySetupComplete()
|
||||||
|
fun onSecuritySetupReset()
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -33,7 +33,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
private var _b: FragmentSecuritySetupBinding? = null
|
private var _b: FragmentSecuritySetupBinding? = null
|
||||||
private val b get() = _b!!
|
private val b get() = _b!!
|
||||||
|
|
||||||
private enum class Step { CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM, BIOMETRIC }
|
private enum class Step { CONFIGURED, CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM }
|
||||||
|
|
||||||
private var step = Step.CHOOSE
|
private var step = Step.CHOOSE
|
||||||
private val pinDigits = mutableListOf<Int>()
|
private val pinDigits = mutableListOf<Int>()
|
||||||
@@ -48,9 +48,6 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false
|
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false
|
||||||
if (!changeMode && prefs.getString("security_method", null) != null) {
|
|
||||||
(activity as? Callback)?.onSecuritySetupComplete()
|
|
||||||
}
|
|
||||||
|
|
||||||
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
|
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
|
||||||
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
|
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
|
||||||
@@ -62,20 +59,21 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
|
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
|
||||||
|
b.btnChangeLock.setOnClickListener {
|
||||||
|
prefs.edit().remove("security_method").apply()
|
||||||
|
(activity as? Callback)?.onSecuritySetupReset()
|
||||||
|
goTo(Step.CHOOSE)
|
||||||
|
}
|
||||||
|
|
||||||
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) }
|
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) }
|
||||||
|
|
||||||
b.btnEnableBiometrics.setOnClickListener {
|
|
||||||
prefs.edit().putBoolean("biometrics_enabled", true).apply()
|
|
||||||
finishSetup()
|
|
||||||
}
|
|
||||||
b.btnSkipBiometrics.setOnClickListener {
|
|
||||||
prefs.edit().putBoolean("biometrics_enabled", false).apply()
|
|
||||||
finishSetup()
|
|
||||||
}
|
|
||||||
|
|
||||||
buildNumpad()
|
buildNumpad()
|
||||||
goTo(Step.CHOOSE)
|
|
||||||
|
if (!changeMode && prefs.getString("security_method", null) != null) {
|
||||||
|
goTo(Step.CONFIGURED)
|
||||||
|
} else {
|
||||||
|
goTo(Step.CHOOSE)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNumpad() {
|
private fun buildNumpad() {
|
||||||
@@ -144,7 +142,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
Step.PIN_CONFIRM -> {
|
Step.PIN_CONFIRM -> {
|
||||||
if (entered == firstPin) {
|
if (entered == firstPin) {
|
||||||
saveCredential("pin", entered)
|
saveCredential("pin", entered)
|
||||||
goToBiometricOrFinish()
|
finishSetup()
|
||||||
} else {
|
} else {
|
||||||
b.tvPinDots.text = getString(R.string.pin_no_match)
|
b.tvPinDots.text = getString(R.string.pin_no_match)
|
||||||
pinDigits.clear()
|
pinDigits.clear()
|
||||||
@@ -172,7 +170,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
Step.PATTERN_CONFIRM -> {
|
Step.PATTERN_CONFIRM -> {
|
||||||
if (pattern == firstPattern) {
|
if (pattern == firstPattern) {
|
||||||
saveCredential("pattern", pattern.joinToString(""))
|
saveCredential("pattern", pattern.joinToString(""))
|
||||||
goToBiometricOrFinish()
|
finishSetup()
|
||||||
} else {
|
} else {
|
||||||
b.patternView.showError()
|
b.patternView.showError()
|
||||||
b.tvPatternStatus.text = getString(R.string.pattern_no_match)
|
b.tvPatternStatus.text = getString(R.string.pattern_no_match)
|
||||||
@@ -187,29 +185,12 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun goToBiometricOrFinish() {
|
|
||||||
// In change mode, biometrics is managed from Settings — skip this step
|
|
||||||
if (arguments?.getBoolean(ARG_CHANGE_MODE, false) == true) {
|
|
||||||
finishSetup()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val canAuth = BiometricManager.from(requireContext())
|
|
||||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
|
|
||||||
if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) {
|
|
||||||
goTo(Step.BIOMETRIC)
|
|
||||||
} else {
|
|
||||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
|
||||||
.edit().putBoolean("biometrics_enabled", false).apply()
|
|
||||||
finishSetup()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun goTo(s: Step) {
|
private fun goTo(s: Step) {
|
||||||
step = s
|
step = s
|
||||||
|
b.viewConfigured.visibility = if (s == Step.CONFIGURED) View.VISIBLE else View.GONE
|
||||||
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE
|
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE
|
||||||
b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE
|
b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE
|
||||||
b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE
|
b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE
|
||||||
b.viewBiometric.visibility = if (s == Step.BIOMETRIC) View.VISIBLE else View.GONE
|
|
||||||
|
|
||||||
when (s) {
|
when (s) {
|
||||||
Step.PIN_ENTER -> {
|
Step.PIN_ENTER -> {
|
||||||
@@ -234,12 +215,10 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
||||||
val hash = pbkdf2(input, salt)
|
val hash = pbkdf2(input, salt)
|
||||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
val edit = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
||||||
.putString("security_method", method)
|
.putString("security_method", method)
|
||||||
// Remove legacy plaintext fields if they exist from an old install
|
if (method == "pin") edit.putInt("pin_length", input.length)
|
||||||
.remove("security_salt")
|
edit.apply()
|
||||||
.remove("security_hash")
|
|
||||||
.apply()
|
|
||||||
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +235,7 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
private fun finishSetup() {
|
private fun finishSetup() {
|
||||||
val cb = activity as? Callback
|
val cb = activity as? Callback
|
||||||
if (cb != null) {
|
if (cb != null) {
|
||||||
|
goTo(Step.CONFIGURED)
|
||||||
cb.onSecuritySetupComplete()
|
cb.onSecuritySetupComplete()
|
||||||
} else {
|
} else {
|
||||||
parentFragmentManager.popBackStack()
|
parentFragmentManager.popBackStack()
|
||||||
|
|||||||
@@ -3,22 +3,23 @@ package sh.sar.basedbank.util
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
object AccountCache {
|
object AccountCache {
|
||||||
|
|
||||||
private const val PREFS = "account_cache"
|
private const val PREFS = "account_cache"
|
||||||
private const val KEY_MIB = "mib_accounts"
|
private const val KEY_MIB = "mib_accounts"
|
||||||
private const val KEY_FAHIPAY = "fahipay_accounts"
|
|
||||||
|
|
||||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||||
|
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||||
|
|
||||||
fun save(context: Context, accounts: List<MibAccount>) {
|
fun save(context: Context, accounts: List<BankAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (acc in accounts) {
|
for (acc in accounts) {
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
|
put("bank", acc.bank)
|
||||||
put("profileName", acc.profileName)
|
put("profileName", acc.profileName)
|
||||||
put("profileType", acc.profileType)
|
put("profileType", acc.profileType)
|
||||||
|
put("cifType", acc.cifType)
|
||||||
put("accountNumber", acc.accountNumber)
|
put("accountNumber", acc.accountNumber)
|
||||||
put("accountBriefName", acc.accountBriefName)
|
put("accountBriefName", acc.accountBriefName)
|
||||||
put("currencyName", acc.currencyName)
|
put("currencyName", acc.currencyName)
|
||||||
@@ -37,7 +38,7 @@ object AccountCache {
|
|||||||
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBml(context: Context, loginId: String, accounts: List<MibAccount>) {
|
fun saveBml(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (acc in accounts) {
|
for (acc in accounts) {
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
@@ -60,37 +61,38 @@ object AccountCache {
|
|||||||
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context, loginId: String): List<MibAccount> {
|
fun loadBml(context: Context, loginId: String): List<BankAccount> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(bmlKey(loginId), null) ?: return emptyList()
|
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibAccount(
|
BankAccount(
|
||||||
profileName = o.optString("profileName"),
|
bank = "BML",
|
||||||
profileType = o.optString("profileType"),
|
profileName = o.optString("profileName"),
|
||||||
accountNumber = o.optString("accountNumber"),
|
profileType = o.optString("profileType"),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountBriefName = o.optString("accountBriefName"),
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
currencyName = o.optString("currencyName"),
|
currencyName = o.optString("currencyName"),
|
||||||
accountTypeName = o.optString("accountTypeName"),
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
availableBalance = o.optString("availableBalance"),
|
availableBalance = o.optString("availableBalance"),
|
||||||
currentBalance = o.optString("currentBalance"),
|
currentBalance = o.optString("currentBalance"),
|
||||||
blockedAmount = o.optString("blockedAmount"),
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
mvrBalance = o.optString("mvrBalance"),
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
statusDesc = o.optString("statusDesc"),
|
statusDesc = o.optString("statusDesc"),
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = o.optString("loginTag"),
|
loginTag = o.optString("loginTag"),
|
||||||
internalId = o.optString("internalId", "")
|
internalId = o.optString("internalId", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
|
fun loadBml(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||||
loginIds.flatMap { loadBml(context, it) }
|
loginIds.flatMap { loadBml(context, it) }
|
||||||
|
|
||||||
fun saveFahipay(context: Context, accounts: List<MibAccount>) {
|
fun saveFahipay(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (acc in accounts) {
|
for (acc in accounts) {
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
@@ -110,41 +112,45 @@ object AccountCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY_FAHIPAY, CacheEncryption.encrypt(arr.toString())).apply()
|
.edit().putString(fahipayKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadFahipay(context: Context): List<MibAccount> {
|
fun loadFahipay(context: Context, loginId: String): List<BankAccount> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_FAHIPAY, null) ?: return emptyList()
|
.getString(fahipayKey(loginId), null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibAccount(
|
BankAccount(
|
||||||
profileName = o.optString("profileName"),
|
bank = "FAHIPAY",
|
||||||
profileType = o.optString("profileType"),
|
profileName = o.optString("profileName"),
|
||||||
accountNumber = o.optString("accountNumber"),
|
profileType = o.optString("profileType"),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountBriefName = o.optString("accountBriefName"),
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
currencyName = o.optString("currencyName"),
|
currencyName = o.optString("currencyName"),
|
||||||
accountTypeName = o.optString("accountTypeName"),
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
availableBalance = o.optString("availableBalance"),
|
availableBalance = o.optString("availableBalance"),
|
||||||
currentBalance = o.optString("currentBalance"),
|
currentBalance = o.optString("currentBalance"),
|
||||||
blockedAmount = o.optString("blockedAmount"),
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
mvrBalance = o.optString("mvrBalance"),
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
statusDesc = o.optString("statusDesc"),
|
statusDesc = o.optString("statusDesc"),
|
||||||
profileImageHash = null,
|
profileImageHash = null,
|
||||||
loginTag = o.optString("loginTag"),
|
loginTag = o.optString("loginTag"),
|
||||||
internalId = o.optString("internalId", "")
|
internalId = o.optString("internalId", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||||
|
loginIds.flatMap { loadFahipay(context, it) }
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<MibAccount> {
|
fun load(context: Context): List<BankAccount> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_MIB, null) ?: return emptyList()
|
.getString(KEY_MIB, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
@@ -152,21 +158,23 @@ object AccountCache {
|
|||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibAccount(
|
BankAccount(
|
||||||
profileName = o.optString("profileName"),
|
bank = o.optString("bank", "MIB"),
|
||||||
profileType = o.optString("profileType"),
|
profileName = o.optString("profileName"),
|
||||||
accountNumber = o.optString("accountNumber"),
|
profileType = o.optString("profileType"),
|
||||||
|
cifType = o.optString("cifType", ""),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
accountBriefName = o.optString("accountBriefName"),
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
currencyName = o.optString("currencyName"),
|
currencyName = o.optString("currencyName"),
|
||||||
accountTypeName = o.optString("accountTypeName"),
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
availableBalance = o.optString("availableBalance"),
|
availableBalance = o.optString("availableBalance"),
|
||||||
currentBalance = o.optString("currentBalance"),
|
currentBalance = o.optString("currentBalance"),
|
||||||
blockedAmount = o.optString("blockedAmount"),
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
mvrBalance = o.optString("mvrBalance"),
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
statusDesc = o.optString("statusDesc"),
|
statusDesc = o.optString("statusDesc"),
|
||||||
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
|
profileImageHash = o.optString("profileImageHash").takeIf { it.isNotBlank() },
|
||||||
loginTag = o.optString("loginTag"),
|
loginTag = o.optString("loginTag"),
|
||||||
profileId = o.optString("profileId", "")
|
profileId = o.optString("profileId", "")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard display model for the account history header card — produced by
|
||||||
|
* per-bank parsers, consumed by AccountHistoryAdapter with no bank logic.
|
||||||
|
*/
|
||||||
|
data class AccountHistoryDisplay(
|
||||||
|
val name: String,
|
||||||
|
val number: String,
|
||||||
|
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
|
||||||
|
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
|
||||||
|
val availableBalance: String, // formatted "CCY amount"
|
||||||
|
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
|
||||||
|
val blockedBalance: String? // null if zero or not applicable
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||||
|
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||||
|
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||||
|
|
||||||
|
object AccountHistoryParser {
|
||||||
|
|
||||||
|
fun from(account: BankAccount): AccountHistoryDisplay? = when (account.bank) {
|
||||||
|
"BML" -> BmlHistoryParser.displayData(account)
|
||||||
|
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||||
|
"MIB" -> MibHistoryParser.displayData(account)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
data class AccountListDisplay(
|
||||||
|
val name: String,
|
||||||
|
val number: String,
|
||||||
|
val typeLabel: String,
|
||||||
|
val balance: String,
|
||||||
|
val isCard: Boolean = false,
|
||||||
|
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
|
||||||
|
val statusLabel: String? = null // null = active; shown as status pill if set
|
||||||
|
)
|
||||||
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal file
16
app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
|
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||||
|
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||||
|
|
||||||
|
object AccountListParser {
|
||||||
|
|
||||||
|
fun from(account: BankAccount): AccountListDisplay? = when (account.bank) {
|
||||||
|
"BML" -> BmlDashboardParser.displayData(account)
|
||||||
|
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||||
|
"MIB" -> MibAccountParser.displayData(account)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/src/main/java/sh/sar/basedbank/util/CardsCache.kt
Normal file
57
app/src/main/java/sh/sar/basedbank/util/CardsCache.kt
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
|
|
||||||
|
object CardsCache {
|
||||||
|
|
||||||
|
private const val PREFS = "cards_cache"
|
||||||
|
private const val KEY_MIB_CARDS = "mib_cards"
|
||||||
|
|
||||||
|
fun save(context: Context, cards: List<MibCard>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (c in cards) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("cardId", c.cardId)
|
||||||
|
put("maskedCardNumber", c.maskedCardNumber)
|
||||||
|
put("cardStatus", c.cardStatus)
|
||||||
|
put("cardType", c.cardType)
|
||||||
|
put("cardTypeDesc", c.cardTypeDesc)
|
||||||
|
put("customerId", c.customerId)
|
||||||
|
put("phoneNumber", c.phoneNumber)
|
||||||
|
put("cardHolderName", c.cardHolderName)
|
||||||
|
put("loginTag", c.loginTag)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(KEY_MIB_CARDS, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(context: Context): List<MibCard> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(KEY_MIB_CARDS, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
MibCard(
|
||||||
|
cardId = o.optString("cardId"),
|
||||||
|
maskedCardNumber = o.optString("maskedCardNumber"),
|
||||||
|
cardStatus = o.optString("cardStatus"),
|
||||||
|
cardType = o.optString("cardType"),
|
||||||
|
cardTypeDesc = o.optString("cardTypeDesc"),
|
||||||
|
customerId = o.optString("customerId"),
|
||||||
|
phoneNumber = o.optString("phoneNumber"),
|
||||||
|
cardHolderName = o.optString("cardHolderName"),
|
||||||
|
loginTag = o.optString("loginTag")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear(context: Context) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal file
22
app/src/main/java/sh/sar/basedbank/util/ContactDisplay.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard display model for a contact row — produced by per-bank parsers,
|
||||||
|
* consumed by UI. No bank-specific logic in adapters or fragments.
|
||||||
|
*/
|
||||||
|
data class ContactDisplay(
|
||||||
|
val id: String, // internal contact ID (benefNo)
|
||||||
|
val name: String, // display nickname
|
||||||
|
val realName: String, // legal name — used for search
|
||||||
|
val accountNumber: String,
|
||||||
|
val categoryId: String, // for tab filtering
|
||||||
|
val network: TransferNetwork,
|
||||||
|
val bankColor: String,
|
||||||
|
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
||||||
|
val imageHash: String?,
|
||||||
|
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
||||||
|
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
||||||
|
val canTransfer: Boolean,
|
||||||
|
val canEdit: Boolean,
|
||||||
|
val canDelete: Boolean
|
||||||
|
)
|
||||||
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal file
18
app/src/main/java/sh/sar/basedbank/util/ContactListParser.kt
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.util.bmlapi.BmlContactParser
|
||||||
|
import sh.sar.basedbank.util.fahipayapi.FahipayContactParser
|
||||||
|
import sh.sar.basedbank.util.mibapi.MibContactParser
|
||||||
|
|
||||||
|
object ContactListParser {
|
||||||
|
|
||||||
|
fun from(contact: BankContact): ContactDisplay? = when {
|
||||||
|
contact.benefCategoryId == "BML" -> BmlContactParser.displayData(contact)
|
||||||
|
contact.benefType == "FAHIPAY" -> FahipayContactParser.displayData(contact)
|
||||||
|
contact.benefType in setOf("I", "L", "S") -> MibContactParser.displayData(contact)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromList(contacts: List<BankContact>): List<ContactDisplay> = contacts.mapNotNull { from(it) }
|
||||||
|
}
|
||||||
40
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal file
40
app/src/main/java/sh/sar/basedbank/util/ContactManager.kt
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behaviour dispatcher for contact operations.
|
||||||
|
* Routes add/delete to the correct bank API based on TransferNetwork.
|
||||||
|
* UI code never inspects the network or bank type directly.
|
||||||
|
*/
|
||||||
|
object ContactManager {
|
||||||
|
|
||||||
|
/** Deletes [contact] via the appropriate bank API. Returns true on success. */
|
||||||
|
suspend fun delete(contact: ContactDisplay, app: BasedBankApp): Boolean = when (contact.network) {
|
||||||
|
TransferNetwork.BML -> deleteBml(contact, app)
|
||||||
|
TransferNetwork.FAHIPAY -> false // Fahipay contacts are read-only
|
||||||
|
TransferNetwork.MIB, TransferNetwork.LOCAL, TransferNetwork.SWIFT -> deleteMib(contact, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteBml(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||||
|
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
||||||
|
val contactId = contact.id.removePrefix("bml_")
|
||||||
|
return try { BmlContactsClient().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteMib(contact: ContactDisplay, app: BasedBankApp): Boolean {
|
||||||
|
val sess = app.anyMibSession() ?: return false
|
||||||
|
return try {
|
||||||
|
if (contact.profileId.isNotBlank()) {
|
||||||
|
val (loginId, profile) = app.mibProfilesMap.entries
|
||||||
|
.firstNotNullOfOrNull { (id, profiles) ->
|
||||||
|
profiles.firstOrNull { it.profileId == contact.profileId }?.let { id to it }
|
||||||
|
} ?: (null to null)
|
||||||
|
if (profile != null && loginId != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
|
}
|
||||||
|
MibContactsClient().deleteContact(sess, contact.id)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ package sh.sar.basedbank.util
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
|
|
||||||
object ContactsCache {
|
object ContactsCache {
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ object ContactsCache {
|
|||||||
|
|
||||||
fun save(
|
fun save(
|
||||||
context: Context,
|
context: Context,
|
||||||
contacts: List<MibBeneficiary>,
|
contacts: List<BankContact>,
|
||||||
categories: List<MibBeneficiaryCategory>
|
categories: List<BankContactCategory>
|
||||||
) {
|
) {
|
||||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ object ContactsCache {
|
|||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadContacts(context: Context): List<MibBeneficiary> {
|
fun loadContacts(context: Context): List<BankContact> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_CONTACTS, null) ?: return emptyList()
|
.getString(KEY_CONTACTS, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
@@ -63,7 +63,7 @@ object ContactsCache {
|
|||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibBeneficiary(
|
BankContact(
|
||||||
benefNo = o.optString("benefNo"),
|
benefNo = o.optString("benefNo"),
|
||||||
benefName = o.optString("benefName"),
|
benefName = o.optString("benefName"),
|
||||||
benefNickName = o.optString("benefNickName"),
|
benefNickName = o.optString("benefNickName"),
|
||||||
@@ -86,7 +86,7 @@ object ContactsCache {
|
|||||||
|
|
||||||
private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
|
private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
|
||||||
|
|
||||||
fun saveBml(context: Context, loginId: String, contacts: List<MibBeneficiary>) {
|
fun saveBml(context: Context, loginId: String, contacts: List<BankContact>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (c in contacts) {
|
for (c in contacts) {
|
||||||
arr.put(JSONObject().apply {
|
arr.put(JSONObject().apply {
|
||||||
@@ -108,14 +108,14 @@ object ContactsCache {
|
|||||||
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
|
fun loadBml(context: Context, loginId: String): List<BankContact> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(bmlKey(loginId), null) ?: return emptyList()
|
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibBeneficiary(
|
BankContact(
|
||||||
benefNo = o.optString("benefNo"),
|
benefNo = o.optString("benefNo"),
|
||||||
benefName = o.optString("benefName"),
|
benefName = o.optString("benefName"),
|
||||||
benefNickName = o.optString("benefNickName"),
|
benefNickName = o.optString("benefNickName"),
|
||||||
@@ -134,10 +134,10 @@ object ContactsCache {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context, loginIds: List<String>): List<MibBeneficiary> =
|
fun loadBml(context: Context, loginIds: List<String>): List<BankContact> =
|
||||||
loginIds.flatMap { loadBml(context, it) }
|
loginIds.flatMap { loadBml(context, it) }
|
||||||
|
|
||||||
fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) {
|
fun saveFahipay(context: Context, contacts: List<BankContact>, categories: List<BankContactCategory>) {
|
||||||
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
for (c in contacts) arr.put(JSONObject().apply {
|
for (c in contacts) arr.put(JSONObject().apply {
|
||||||
@@ -159,14 +159,14 @@ object ContactsCache {
|
|||||||
prefs.apply()
|
prefs.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadFahipay(context: Context): List<MibBeneficiary> {
|
fun loadFahipay(context: Context): List<BankContact> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString("fahipay_contacts", null) ?: return emptyList()
|
.getString("fahipay_contacts", null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibBeneficiary(
|
BankContact(
|
||||||
benefNo = o.optString("benefNo"),
|
benefNo = o.optString("benefNo"),
|
||||||
benefName = "",
|
benefName = "",
|
||||||
benefNickName = o.optString("benefNickName"),
|
benefNickName = o.optString("benefNickName"),
|
||||||
@@ -185,19 +185,19 @@ object ContactsCache {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadFahipayCategories(context: Context): List<MibBeneficiaryCategory> {
|
fun loadFahipayCategories(context: Context): List<BankContactCategory> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString("fahipay_categories", null) ?: return emptyList()
|
.getString("fahipay_categories", null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibBeneficiaryCategory(o.optString("id"), o.optString("categoryName"), o.optInt("numBenef"))
|
BankContactCategory(o.optString("id"), o.optString("categoryName"), o.optInt("numBenef"))
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
|
fun loadCategories(context: Context): List<BankContactCategory> {
|
||||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_CATEGORIES, null) ?: return emptyList()
|
.getString(KEY_CATEGORIES, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
@@ -205,7 +205,7 @@ object ContactsCache {
|
|||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
MibBeneficiaryCategory(
|
BankContactCategory(
|
||||||
id = o.optString("id"),
|
id = o.optString("id"),
|
||||||
categoryName = o.optString("categoryName"),
|
categoryName = o.optString("categoryName"),
|
||||||
numBenef = o.optInt("numBenef", 0)
|
numBenef = o.optInt("numBenef", 0)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.security.keystore.KeyGenParameterSpec
|
|||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
@@ -21,104 +22,138 @@ class CredentialStore(context: Context) {
|
|||||||
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
||||||
data class FahipayCredentials(val idCard: String, val password: String)
|
data class FahipayCredentials(val idCard: String, val password: String)
|
||||||
|
|
||||||
// ── MIB login credentials ─────────────────────────────────────────────────
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
|
|
||||||
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
|
fun getMibLoginIds(): List<String> {
|
||||||
fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card")
|
maybeMigrateLegacyMib()
|
||||||
|
val json = prefs.getString("mib_login_ids", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(json)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
|
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
private fun addMibLoginId(loginId: String) {
|
||||||
|
val ids = getMibLoginIds().toMutableList()
|
||||||
|
if (loginId !in ids) {
|
||||||
|
ids.add(loginId)
|
||||||
|
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeMibLoginId(loginId: String) {
|
||||||
|
val ids = getMibLoginIds().toMutableList()
|
||||||
|
if (ids.remove(loginId))
|
||||||
|
prefs.edit().putString("mib_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMibCredentials(loginId: String, username: String, passwordHash: String, otpSeed: String) {
|
||||||
|
addMibLoginId(loginId)
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("mib_enc_username", encrypt(username, key))
|
.putString("mib_${loginId}_enc_password_hash", encrypt(passwordHash, key))
|
||||||
.putString("mib_enc_password_hash", encrypt(passwordHash, key))
|
.putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
|
||||||
.putString("mib_enc_otp_seed", encrypt(otpSeed, key))
|
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibCredentials(): MibCredentials? {
|
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encUsername = prefs.getString("mib_enc_username", null) ?: return null
|
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||||
val encHash = prefs.getString("mib_enc_password_hash", null) ?: return null
|
val encSeed = prefs.getString("mib_${loginId}_enc_otp_seed", null) ?: return null
|
||||||
val encSeed = prefs.getString("mib_enc_otp_seed", null) ?: return null
|
|
||||||
return try {
|
return try {
|
||||||
MibCredentials(
|
MibCredentials(loginId, decrypt(encHash, key), decrypt(encSeed, key))
|
||||||
decrypt(encUsername, key),
|
|
||||||
decrypt(encHash, key),
|
|
||||||
decrypt(encSeed, key)
|
|
||||||
)
|
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearMibCredentials() {
|
fun clearMibCredentials(loginId: String) {
|
||||||
|
removeMibLoginId(loginId)
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("mib_enc_username")
|
.remove("mib_${loginId}_enc_password_hash")
|
||||||
.remove("mib_enc_password_hash")
|
.remove("mib_${loginId}_enc_otp_seed")
|
||||||
.remove("mib_enc_otp_seed")
|
.remove("mib_${loginId}_enc_key1")
|
||||||
.remove("mib_enc_key1")
|
.remove("mib_${loginId}_enc_key2")
|
||||||
.remove("mib_enc_key2")
|
.remove("mib_${loginId}_enc_app_id")
|
||||||
.remove("mib_enc_app_id")
|
.remove("mib_${loginId}_all_profiles")
|
||||||
|
.remove("mib_${loginId}_enc_profile")
|
||||||
|
.remove("mib_${loginId}_enc_full_name")
|
||||||
|
.remove("mib_${loginId}_hidden_profile_ids")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── MIB session keys (key1/key2) and app ID ───────────────────────────────
|
// ── MIB session keys (key1/key2) and app ID (per loginId) ────────────────
|
||||||
|
|
||||||
fun saveMibKeys(key1: String, key2: String) {
|
fun saveMibKeys(loginId: String, key1: String, key2: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("mib_enc_key1", encrypt(key1, key))
|
.putString("mib_${loginId}_enc_key1", encrypt(key1, key))
|
||||||
.putString("mib_enc_key2", encrypt(key2, key))
|
.putString("mib_${loginId}_enc_key2", encrypt(key2, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibKeys(): Pair<String, String>? {
|
fun loadMibKeys(loginId: String): Pair<String, String>? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null
|
val encKey1 = prefs.getString("mib_${loginId}_enc_key1", null) ?: return null
|
||||||
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null
|
val encKey2 = prefs.getString("mib_${loginId}_enc_key2", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
|
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveMibAppId(id: String) {
|
fun saveMibAppId(loginId: String, id: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply()
|
prefs.edit().putString("mib_${loginId}_enc_app_id", encrypt(id, key)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibAppId(): String? {
|
fun loadMibAppId(loginId: String): String? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val enc = prefs.getString("mib_enc_app_id", null) ?: return null
|
val enc = prefs.getString("mib_${loginId}_enc_app_id", null) ?: return null
|
||||||
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-time migration: if old single-login MIB data exists, move it to per-loginId storage. */
|
||||||
|
private var migrationChecked = false
|
||||||
|
private fun maybeMigrateLegacyMib() {
|
||||||
|
if (migrationChecked) return
|
||||||
|
migrationChecked = true
|
||||||
|
if (prefs.contains("mib_login_ids")) return // already migrated
|
||||||
|
val encUsername = prefs.getString("mib_enc_username", null) ?: return
|
||||||
|
val key = try { getOrCreateKey() } catch (_: Exception) { return }
|
||||||
|
val loginId = try { decrypt(encUsername, key) } catch (_: Exception) { return }
|
||||||
|
val editor = prefs.edit()
|
||||||
|
// Migrate credentials
|
||||||
|
prefs.getString("mib_enc_password_hash", null)?.let { editor.putString("mib_${loginId}_enc_password_hash", it) }
|
||||||
|
prefs.getString("mib_enc_otp_seed", null)?.let { editor.putString("mib_${loginId}_enc_otp_seed", it) }
|
||||||
|
prefs.getString("mib_enc_key1", null)?.let { editor.putString("mib_${loginId}_enc_key1", it) }
|
||||||
|
prefs.getString("mib_enc_key2", null)?.let { editor.putString("mib_${loginId}_enc_key2", it) }
|
||||||
|
prefs.getString("mib_enc_app_id", null)?.let { editor.putString("mib_${loginId}_enc_app_id", it) }
|
||||||
|
prefs.getString("mib_all_profiles", null)?.let { editor.putString("mib_${loginId}_all_profiles", it) }
|
||||||
|
prefs.getString("mib_enc_profile", null)?.let { editor.putString("mib_${loginId}_enc_profile", it) }
|
||||||
|
prefs.getString("mib_enc_full_name", null)?.let { editor.putString("mib_${loginId}_enc_full_name", it) }
|
||||||
|
prefs.getStringSet("mib_hidden_profile_ids", null)?.let { editor.putStringSet("mib_${loginId}_hidden_profile_ids", it) }
|
||||||
|
// Register the login ID and clear legacy keys
|
||||||
|
editor.putString("mib_login_ids", org.json.JSONArray(listOf(loginId)).toString())
|
||||||
|
editor.remove("mib_enc_username")
|
||||||
|
editor.remove("mib_enc_password_hash")
|
||||||
|
editor.remove("mib_enc_otp_seed")
|
||||||
|
editor.remove("mib_enc_key1")
|
||||||
|
editor.remove("mib_enc_key2")
|
||||||
|
editor.remove("mib_enc_app_id")
|
||||||
|
editor.remove("mib_all_profiles")
|
||||||
|
editor.remove("mib_enc_profile")
|
||||||
|
editor.remove("mib_enc_full_name")
|
||||||
|
editor.remove("mib_hidden_profile_ids")
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
// ── BML login credentials (multi-login, keyed by loginId = username) ────────
|
// ── BML login credentials (multi-login, keyed by loginId = username) ────────
|
||||||
|
|
||||||
fun getBmlLoginIds(): List<String> {
|
fun getBmlLoginIds(): List<String> {
|
||||||
val json = prefs.getString("bml_login_ids", null)
|
val json = prefs.getString("bml_login_ids", null) ?: return emptyList()
|
||||||
if (json != null) {
|
|
||||||
return try {
|
|
||||||
val arr = org.json.JSONArray(json)
|
|
||||||
(0 until arr.length()).map { arr.getString(it) }
|
|
||||||
} catch (_: Exception) { emptyList() }
|
|
||||||
}
|
|
||||||
// One-time migration from single-slot BML storage
|
|
||||||
val oldEncUsername = prefs.getString("bml_enc_username", null) ?: return emptyList()
|
|
||||||
return try {
|
return try {
|
||||||
val key = getOrCreateKey()
|
val arr = org.json.JSONArray(json)
|
||||||
val loginId = decrypt(oldEncUsername, key)
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
val edit = prefs.edit()
|
|
||||||
prefs.getString("bml_enc_password", null)?.let { edit.putString("bml_${loginId}_enc_password", it) }
|
|
||||||
prefs.getString("bml_enc_otp_seed", null)?.let { edit.putString("bml_${loginId}_enc_otp_seed", it) }
|
|
||||||
prefs.getString("bml_enc_token", null)?.let { edit.putString("bml_${loginId}_enc_token", it) }
|
|
||||||
prefs.getString("bml_enc_device_id", null)?.let { edit.putString("bml_${loginId}_enc_device_id", it) }
|
|
||||||
prefs.getString("bml_enc_profile", null)?.let { edit.putString("bml_${loginId}_enc_profile", it) }
|
|
||||||
edit.putString("bml_${loginId}_enc_username", oldEncUsername)
|
|
||||||
edit.remove("bml_enc_username").remove("bml_enc_password").remove("bml_enc_otp_seed")
|
|
||||||
.remove("bml_enc_token").remove("bml_enc_device_id")
|
|
||||||
.remove("bml_enc_profile").remove("bml_enc_full_name")
|
|
||||||
val ids = org.json.JSONArray(listOf(loginId)).toString()
|
|
||||||
edit.putString("bml_login_ids", ids)
|
|
||||||
edit.apply()
|
|
||||||
listOf(loginId)
|
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,24 +194,105 @@ class CredentialStore(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlCredentials(loginId: String) {
|
fun clearBmlCredentials(loginId: String) {
|
||||||
|
loadBmlProfiles(loginId).forEach { clearBmlProfileSession(it.profileId) }
|
||||||
removeBmlLoginId(loginId)
|
removeBmlLoginId(loginId)
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_${loginId}_enc_username")
|
.remove("bml_${loginId}_enc_username")
|
||||||
.remove("bml_${loginId}_enc_password")
|
.remove("bml_${loginId}_enc_password")
|
||||||
.remove("bml_${loginId}_enc_otp_seed")
|
.remove("bml_${loginId}_enc_otp_seed")
|
||||||
|
.remove("bml_${loginId}_profiles")
|
||||||
|
.remove("bml_${loginId}_hidden_profile_ids")
|
||||||
|
// legacy single-profile session keys
|
||||||
|
.remove("bml_${loginId}_enc_token")
|
||||||
|
.remove("bml_${loginId}_enc_device_id")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── BML session token (per loginId) ───────────────────────────────────────
|
// ── BML profiles (per loginId) ────────────────────────────────────────────
|
||||||
|
|
||||||
fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
|
fun saveBmlProfiles(loginId: String, profiles: List<BmlProfile>) {
|
||||||
|
val arr = org.json.JSONArray()
|
||||||
|
for (p in profiles) arr.put(org.json.JSONObject().apply {
|
||||||
|
put("profileId", p.profileId)
|
||||||
|
put("name", p.name)
|
||||||
|
put("type", p.type)
|
||||||
|
put("profileType", p.profileType)
|
||||||
|
put("autoActivated", p.autoActivated)
|
||||||
|
})
|
||||||
|
prefs.edit().putString("bml_${loginId}_profiles", arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfiles(loginId: String): List<BmlProfile> {
|
||||||
|
val raw = prefs.getString("bml_${loginId}_profiles", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BmlProfile(
|
||||||
|
profileId = o.optString("profileId"),
|
||||||
|
name = o.optString("name"),
|
||||||
|
type = o.optString("type"),
|
||||||
|
profileType = o.optString("profileType", "default"),
|
||||||
|
autoActivated = o.optBoolean("autoActivated", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getHiddenBmlProfileIds(loginId: String): Set<String> =
|
||||||
|
prefs.getStringSet("bml_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenBmlProfileIds(loginId: String, ids: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("bml_${loginId}_hidden_profile_ids", ids).apply()
|
||||||
|
|
||||||
|
// ── BML per-profile session token (keyed by profileId, a globally unique GUID) ──
|
||||||
|
|
||||||
|
fun saveBmlProfileSession(profileId: String, accessToken: String, deviceId: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
|
.putString("bml_profile_${profileId}_enc_token", encrypt(accessToken, key))
|
||||||
.putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
|
.putString("bml_profile_${profileId}_enc_device_id", encrypt(deviceId, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfileSession(profileId: String): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encToken = prefs.getString("bml_profile_${profileId}_enc_token", null) ?: return null
|
||||||
|
val encDeviceId = prefs.getString("bml_profile_${profileId}_enc_device_id", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBmlProfileExpiresAt(profileId: String, expiresAt: Long) {
|
||||||
|
prefs.edit().putLong("bml_profile_${profileId}_expires_at", expiresAt).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfileExpiresAt(profileId: String): Long =
|
||||||
|
prefs.getLong("bml_profile_${profileId}_expires_at", 0L)
|
||||||
|
|
||||||
|
fun saveBmlProfileRefreshToken(profileId: String, refreshToken: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("bml_profile_${profileId}_enc_refresh_token", encrypt(refreshToken, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlProfileRefreshToken(profileId: String): String? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("bml_profile_${profileId}_enc_refresh_token", null) ?: return null
|
||||||
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearBmlProfileSession(profileId: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("bml_profile_${profileId}_enc_token")
|
||||||
|
.remove("bml_profile_${profileId}_enc_device_id")
|
||||||
|
.remove("bml_profile_${profileId}_enc_refresh_token")
|
||||||
|
.remove("bml_profile_${profileId}_expires_at")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy single-profile BML session (kept for backward compat reading) ─
|
||||||
|
|
||||||
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
fun loadBmlSession(loginId: String): Pair<String, String>? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
|
||||||
@@ -186,65 +302,81 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlSession(loginId: String) {
|
// ── Fahipay login credentials (multi-login, keyed by loginId = profileId) ──
|
||||||
|
|
||||||
|
fun getFahipayLoginIds(): List<String> {
|
||||||
|
maybeMigrateLegacyFahipay()
|
||||||
|
val json = prefs.getString("fahipay_login_ids", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(json)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
private fun addFahipayLoginId(loginId: String) {
|
||||||
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
|
if (loginId !in ids) {
|
||||||
|
ids.add(loginId)
|
||||||
|
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeFahipayLoginId(loginId: String) {
|
||||||
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
|
if (ids.remove(loginId))
|
||||||
|
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveFahipayCredentials(loginId: String, idCard: String, password: String) {
|
||||||
|
addFahipayLoginId(loginId)
|
||||||
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("bml_${loginId}_enc_token")
|
.putString("fahipay_${loginId}_enc_id_card", encrypt(idCard, key))
|
||||||
.remove("bml_${loginId}_enc_device_id")
|
.putString("fahipay_${loginId}_enc_password", encrypt(password, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fahipay login credentials ─────────────────────────────────────────────
|
fun loadFahipayCredentials(loginId: String): FahipayCredentials? {
|
||||||
|
|
||||||
fun saveFahipayCredentials(idCard: String, password: String) {
|
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
val encId = prefs.getString("fahipay_${loginId}_enc_id_card", null) ?: return null
|
||||||
.putString("fahipay_enc_id_card", encrypt(idCard, key))
|
val encPw = prefs.getString("fahipay_${loginId}_enc_password", null) ?: return null
|
||||||
.putString("fahipay_enc_password", encrypt(password, key))
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadFahipayCredentials(): FahipayCredentials? {
|
|
||||||
val key = getOrCreateKey()
|
|
||||||
val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null
|
|
||||||
val encPw = prefs.getString("fahipay_enc_password", null) ?: return null
|
|
||||||
return try {
|
return try {
|
||||||
FahipayCredentials(decrypt(encId, key), decrypt(encPw, key))
|
FahipayCredentials(decrypt(encId, key), decrypt(encPw, key))
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearFahipayCredentials() {
|
fun clearFahipayCredentials(loginId: String) {
|
||||||
|
removeFahipayLoginId(loginId)
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.remove("fahipay_enc_id_card")
|
.remove("fahipay_${loginId}_enc_id_card")
|
||||||
.remove("fahipay_enc_password")
|
.remove("fahipay_${loginId}_enc_password")
|
||||||
|
.remove("fahipay_${loginId}_enc_auth_id")
|
||||||
|
.remove("fahipay_${loginId}_enc_session_cookie")
|
||||||
|
.remove("fahipay_${loginId}_enc_profile")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fahipay session (authId + __Secure-sess cookie) ───────────────────────
|
// ── Fahipay session (authId + __Secure-sess cookie) (per loginId) ─────────
|
||||||
|
|
||||||
fun saveFahipaySession(authId: String, sessionCookie: String) {
|
fun saveFahipaySession(loginId: String, authId: String, sessionCookie: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("fahipay_enc_auth_id", encrypt(authId, key))
|
.putString("fahipay_${loginId}_enc_auth_id", encrypt(authId, key))
|
||||||
.putString("fahipay_enc_session_cookie", encrypt(sessionCookie, key))
|
.putString("fahipay_${loginId}_enc_session_cookie", encrypt(sessionCookie, key))
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadFahipaySession(): Pair<String, String>? {
|
fun loadFahipaySession(loginId: String): Pair<String, String>? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encAuth = prefs.getString("fahipay_enc_auth_id", null) ?: return null
|
val encAuth = prefs.getString("fahipay_${loginId}_enc_auth_id", null) ?: return null
|
||||||
val encCookie = prefs.getString("fahipay_enc_session_cookie", null) ?: return null
|
val encCookie = prefs.getString("fahipay_${loginId}_enc_session_cookie", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
Pair(decrypt(encAuth, key), decrypt(encCookie, key))
|
Pair(decrypt(encAuth, key), decrypt(encCookie, key))
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearFahipaySession() {
|
|
||||||
prefs.edit()
|
|
||||||
.remove("fahipay_enc_auth_id")
|
|
||||||
.remove("fahipay_enc_session_cookie")
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─
|
// ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─
|
||||||
|
|
||||||
fun getOrCreateFahipayDeviceUuid(): String {
|
fun getOrCreateFahipayDeviceUuid(): String {
|
||||||
@@ -258,7 +390,7 @@ class CredentialStore(context: Context) {
|
|||||||
return uuid
|
return uuid
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fahipay user profile ──────────────────────────────────────────────────
|
// ── Fahipay user profile (per loginId) ────────────────────────────────────
|
||||||
|
|
||||||
data class FahipayUserProfile(
|
data class FahipayUserProfile(
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
@@ -270,7 +402,7 @@ class CredentialStore(context: Context) {
|
|||||||
val linkedAccounts: String
|
val linkedAccounts: String
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveFahipayUserProfile(p: FahipayUserProfile) {
|
fun saveFahipayUserProfile(loginId: String, p: FahipayUserProfile) {
|
||||||
val json = org.json.JSONObject().apply {
|
val json = org.json.JSONObject().apply {
|
||||||
put("fullName", p.fullName)
|
put("fullName", p.fullName)
|
||||||
put("email", p.email)
|
put("email", p.email)
|
||||||
@@ -281,12 +413,12 @@ class CredentialStore(context: Context) {
|
|||||||
put("linkedAccounts", p.linkedAccounts)
|
put("linkedAccounts", p.linkedAccounts)
|
||||||
}.toString()
|
}.toString()
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit().putString("fahipay_enc_profile", encrypt(json, key)).apply()
|
prefs.edit().putString("fahipay_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadFahipayUserProfile(): FahipayUserProfile? {
|
fun loadFahipayUserProfile(loginId: String): FahipayUserProfile? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val enc = prefs.getString("fahipay_enc_profile", null) ?: return null
|
val enc = prefs.getString("fahipay_${loginId}_enc_profile", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
val o = org.json.JSONObject(decrypt(enc, key))
|
val o = org.json.JSONObject(decrypt(enc, key))
|
||||||
FahipayUserProfile(
|
FahipayUserProfile(
|
||||||
@@ -301,6 +433,33 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One-time migration: if old single-login Fahipay data exists, move it to per-loginId storage. */
|
||||||
|
private var fahipayMigrationChecked = false
|
||||||
|
private fun maybeMigrateLegacyFahipay() {
|
||||||
|
if (fahipayMigrationChecked) return
|
||||||
|
fahipayMigrationChecked = true
|
||||||
|
if (prefs.contains("fahipay_login_ids")) return // already migrated
|
||||||
|
val encProfile = prefs.getString("fahipay_enc_profile", null) ?: return
|
||||||
|
val key = try { getOrCreateKey() } catch (_: Exception) { return }
|
||||||
|
val loginId = try {
|
||||||
|
val o = org.json.JSONObject(decrypt(encProfile, key))
|
||||||
|
o.optString("profileId").takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) { null } ?: return
|
||||||
|
val editor = prefs.edit()
|
||||||
|
prefs.getString("fahipay_enc_id_card", null)?.let { editor.putString("fahipay_${loginId}_enc_id_card", it) }
|
||||||
|
prefs.getString("fahipay_enc_password", null)?.let { editor.putString("fahipay_${loginId}_enc_password", it) }
|
||||||
|
prefs.getString("fahipay_enc_auth_id", null)?.let { editor.putString("fahipay_${loginId}_enc_auth_id", it) }
|
||||||
|
prefs.getString("fahipay_enc_session_cookie", null)?.let { editor.putString("fahipay_${loginId}_enc_session_cookie", it) }
|
||||||
|
editor.putString("fahipay_${loginId}_enc_profile", encProfile)
|
||||||
|
editor.putString("fahipay_login_ids", org.json.JSONArray(listOf(loginId)).toString())
|
||||||
|
editor.remove("fahipay_enc_id_card")
|
||||||
|
editor.remove("fahipay_enc_password")
|
||||||
|
editor.remove("fahipay_enc_auth_id")
|
||||||
|
editor.remove("fahipay_enc_session_cookie")
|
||||||
|
editor.remove("fahipay_enc_profile")
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -351,19 +510,55 @@ class CredentialStore(context: Context) {
|
|||||||
val birthdate: String
|
val birthdate: String
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveMibFullName(name: String) {
|
// ── MIB operating profiles (per loginId) ─────────────────────────────────
|
||||||
val key = getOrCreateKey()
|
|
||||||
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
|
fun saveMibProfiles(loginId: String, profiles: List<sh.sar.basedbank.api.mib.MibProfile>) {
|
||||||
|
val arr = org.json.JSONArray()
|
||||||
|
for (p in profiles) {
|
||||||
|
arr.put(org.json.JSONObject().apply {
|
||||||
|
put("profileId", p.profileId)
|
||||||
|
put("name", p.name)
|
||||||
|
put("cifType", p.cifType)
|
||||||
|
put("profileType", p.profileType)
|
||||||
|
put("color", p.color)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prefs.edit().putString("mib_${loginId}_all_profiles", arr.toString()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibFullName(): String? {
|
fun loadMibProfiles(loginId: String): List<sh.sar.basedbank.api.mib.MibProfile> {
|
||||||
|
val raw = prefs.getString("mib_${loginId}_all_profiles", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
sh.sar.basedbank.api.mib.MibProfile(
|
||||||
|
profileId = o.optString("profileId"),
|
||||||
|
customerProfileId = o.optString("profileId"),
|
||||||
|
annexId = "",
|
||||||
|
customerId = "",
|
||||||
|
name = o.optString("name"),
|
||||||
|
cifType = o.optString("cifType"),
|
||||||
|
profileType = o.optString("profileType"),
|
||||||
|
color = o.optString("color"),
|
||||||
|
customerImage = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMibFullName(loginId: String, name: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val enc = prefs.getString("mib_enc_full_name", null) ?: return null
|
prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(name, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMibFullName(loginId: String): String? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("mib_${loginId}_enc_full_name", null) ?: return null
|
||||||
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveMibUserProfile(loginId: String, p: MibUserProfile) {
|
||||||
fun saveMibUserProfile(p: MibUserProfile) {
|
|
||||||
val json = JSONObject().apply {
|
val json = JSONObject().apply {
|
||||||
put("fullName", p.fullName)
|
put("fullName", p.fullName)
|
||||||
put("username", p.username)
|
put("username", p.username)
|
||||||
@@ -372,14 +567,13 @@ class CredentialStore(context: Context) {
|
|||||||
put("enrolled", p.enrolled)
|
put("enrolled", p.enrolled)
|
||||||
}.toString()
|
}.toString()
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply()
|
prefs.edit().putString("mib_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||||
// Keep the name in sync with the fast-path field
|
prefs.edit().putString("mib_${loginId}_enc_full_name", encrypt(p.fullName, key)).apply()
|
||||||
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibUserProfile(): MibUserProfile? {
|
fun loadMibUserProfile(loginId: String): MibUserProfile? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val enc = prefs.getString("mib_enc_profile", null) ?: return null
|
val enc = prefs.getString("mib_${loginId}_enc_profile", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
val o = JSONObject(decrypt(enc, key))
|
val o = JSONObject(decrypt(enc, key))
|
||||||
MibUserProfile(
|
MibUserProfile(
|
||||||
@@ -421,6 +615,15 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MIB profile visibility (per loginId) ─────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
|
||||||
|
fun getHiddenMibProfileIds(loginId: String): Set<String> =
|
||||||
|
prefs.getStringSet("mib_${loginId}_hidden_profile_ids", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
||||||
|
|
||||||
// ── Crypto primitives ─────────────────────────────────────────────────────
|
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun getOrCreateKey(): SecretKey {
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package sh.sar.basedbank.util
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
|
||||||
object FinancingCache {
|
object FinancingCache {
|
||||||
|
|
||||||
private const val PREFS = "financing_cache"
|
private const val PREFS = "financing_cache"
|
||||||
private const val KEY_MIB = "mib_financing"
|
private const val KEY_MIB = "mib_financing"
|
||||||
|
private const val KEY_BML_LOANS = "bml_loans"
|
||||||
|
|
||||||
fun save(context: Context, deals: List<MibFinanceDeal>) {
|
fun save(context: Context, deals: List<MibFinanceDeal>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
@@ -34,6 +36,52 @@ object FinancingCache {
|
|||||||
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun saveBmlLoans(context: Context, loans: Map<String, BmlLoanDetail>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for ((internalId, d) in loans) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("internalId", internalId)
|
||||||
|
put("loanAmount", d.loanAmount)
|
||||||
|
put("outstandingAmt", d.outstandingAmt)
|
||||||
|
put("repayAmount", d.repayAmount)
|
||||||
|
put("intRate", d.intRate)
|
||||||
|
put("loanStatus", d.loanStatus)
|
||||||
|
put("startDate", d.startDate)
|
||||||
|
put("endDate", d.endDate)
|
||||||
|
put("noOfRepayOverdue", d.noOfRepayOverdue)
|
||||||
|
put("overdueAmount", d.overdueAmount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(KEY_BML_LOANS, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlLoans(context: Context): Map<String, BmlLoanDetail> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(KEY_BML_LOANS, null) ?: return emptyMap()
|
||||||
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
buildMap {
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val id = o.optString("internalId")
|
||||||
|
if (id.isNotBlank()) put(id, BmlLoanDetail(
|
||||||
|
loanAmount = o.optDouble("loanAmount", 0.0),
|
||||||
|
outstandingAmt = o.optDouble("outstandingAmt", 0.0),
|
||||||
|
repayAmount = o.optDouble("repayAmount", 0.0),
|
||||||
|
intRate = o.optDouble("intRate", 0.0),
|
||||||
|
loanStatus = o.optString("loanStatus"),
|
||||||
|
startDate = o.optString("startDate"),
|
||||||
|
endDate = o.optString("endDate"),
|
||||||
|
noOfRepayOverdue = o.optInt("noOfRepayOverdue", 0),
|
||||||
|
overdueAmount = o.optDouble("overdueAmount", 0.0)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyMap() }
|
||||||
|
}
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|||||||
118
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal file
118
app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encapsulates all bank-specific pagination state and fetch logic for account history.
|
||||||
|
* The fragment holds one instance per account and calls [hasMore] / [fetchNextPage]
|
||||||
|
* without knowing which bank it is talking to.
|
||||||
|
*/
|
||||||
|
class HistoryFetcher(private val account: BankAccount) {
|
||||||
|
|
||||||
|
private val isMib get() = account.bank == "MIB"
|
||||||
|
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||||
|
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
||||||
|
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||||
|
|
||||||
|
// MIB pagination
|
||||||
|
private var mibNextStart = 1
|
||||||
|
private var mibTotalCount = -1
|
||||||
|
|
||||||
|
// BML CASA pagination
|
||||||
|
private var bmlNextPage = 1
|
||||||
|
private var bmlTotalPages = -1
|
||||||
|
|
||||||
|
// BML card pagination (month-based)
|
||||||
|
private var cardMonthOffset = 0
|
||||||
|
|
||||||
|
// Fahipay pagination
|
||||||
|
private var fahipayNextStart = 0
|
||||||
|
private var fahipayTotal = -1
|
||||||
|
|
||||||
|
fun hasMore(): Boolean = when {
|
||||||
|
isBmlLoan -> false
|
||||||
|
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
|
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
|
isBmlCard -> cardMonthOffset < 3
|
||||||
|
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||||
|
isBmlLoan -> emptyList()
|
||||||
|
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||||
|
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||||
|
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||||
|
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
||||||
|
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||||
|
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
start = fahipayNextStart
|
||||||
|
)
|
||||||
|
if (total > 0) fahipayTotal = total
|
||||||
|
fahipayNextStart += list.size
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchMib(app: BasedBankApp, pageSize: Int): List<BankTransaction> {
|
||||||
|
val loginId = account.loginTag.removePrefix("mib_")
|
||||||
|
val session = app.mibSessions[loginId] ?: return emptyList()
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == account.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||||
|
val (list, total) = MibHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNo = account.accountNumber,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
start = mibNextStart,
|
||||||
|
pageSize = pageSize
|
||||||
|
)
|
||||||
|
if (total > 0) mibTotalCount = total
|
||||||
|
mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
|
||||||
|
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||||
|
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||||
|
cardMonthOffset++
|
||||||
|
return BmlHistoryClient().fetchCardHistory(
|
||||||
|
session = session,
|
||||||
|
cardId = account.internalId,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
month = month
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> {
|
||||||
|
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||||
|
val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
|
||||||
|
session = session,
|
||||||
|
accountId = account.internalId,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
page = bmlNextPage
|
||||||
|
)
|
||||||
|
if (totalPages > 0) bmlTotalPages = totalPages
|
||||||
|
bmlNextPage++
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
83
app/src/main/java/sh/sar/basedbank/util/ReceiptStore.kt
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Persistent (non-cache) store for completed transfer receipts shown in Recent Transfers. */
|
||||||
|
object ReceiptStore {
|
||||||
|
|
||||||
|
private const val FILE_NAME = "activities.json"
|
||||||
|
|
||||||
|
data class Entry(val data: TransferReceiptData, val savedAt: Long)
|
||||||
|
|
||||||
|
fun save(context: Context, receipt: TransferReceiptData) {
|
||||||
|
val existing = loadAll(context).toMutableList()
|
||||||
|
existing.add(0, Entry(receipt, System.currentTimeMillis()))
|
||||||
|
writeAll(context, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadAll(context: Context): List<Entry> {
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists()) return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
Entry(
|
||||||
|
data = TransferReceiptData(
|
||||||
|
bank = o.optString("bank", "MIB"),
|
||||||
|
amount = o.optString("amount"),
|
||||||
|
currency = o.optString("currency"),
|
||||||
|
fromLabel = o.optString("fromLabel"),
|
||||||
|
fromColorHex = o.optString("fromColorHex"),
|
||||||
|
fromProfileImageHash = o.optString("fromProfileImageHash").takeIf { it.isNotBlank() },
|
||||||
|
toLabel = o.optString("toLabel"),
|
||||||
|
toAccount = o.optString("toAccount"),
|
||||||
|
toBank = o.optString("toBank"),
|
||||||
|
remarks = o.optString("remarks"),
|
||||||
|
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||||
|
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||||
|
bmlFromName = o.optString("bmlFromName"),
|
||||||
|
bmlReference = o.optString("bmlReference"),
|
||||||
|
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||||
|
bmlMessage = o.optString("bmlMessage")
|
||||||
|
),
|
||||||
|
savedAt = o.optLong("savedAt", 0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll(context: Context) {
|
||||||
|
File(context.filesDir, FILE_NAME).delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeAll(context: Context, items: List<Entry>) {
|
||||||
|
try {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for ((d, ts) in items) arr.put(JSONObject().apply {
|
||||||
|
put("bank", d.bank)
|
||||||
|
put("amount", d.amount)
|
||||||
|
put("currency", d.currency)
|
||||||
|
put("fromLabel", d.fromLabel)
|
||||||
|
put("fromColorHex", d.fromColorHex)
|
||||||
|
put("fromProfileImageHash", d.fromProfileImageHash ?: "")
|
||||||
|
put("toLabel", d.toLabel)
|
||||||
|
put("toAccount", d.toAccount)
|
||||||
|
put("toBank", d.toBank)
|
||||||
|
put("remarks", d.remarks)
|
||||||
|
put("mibReferenceNo", d.mibReferenceNo)
|
||||||
|
put("mibTransactionDate", d.mibTransactionDate)
|
||||||
|
put("bmlFromName", d.bmlFromName)
|
||||||
|
put("bmlReference", d.bmlReference)
|
||||||
|
put("bmlTimestamp", d.bmlTimestamp)
|
||||||
|
put("bmlMessage", d.bmlMessage)
|
||||||
|
put("savedAt", ts)
|
||||||
|
})
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal file
10
app/src/main/java/sh/sar/basedbank/util/TransferNetwork.kt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
/** App-unified term for the transfer routing network a contact uses. */
|
||||||
|
enum class TransferNetwork {
|
||||||
|
MIB, // MIB internal (both parties on MIB)
|
||||||
|
LOCAL, // local inter-bank via IPS (e.g. BML from MIB's side)
|
||||||
|
SWIFT, // international SWIFT
|
||||||
|
BML, // Bank of Maldives
|
||||||
|
FAHIPAY // Fahipay wallet
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
object BmlContactParser {
|
||||||
|
|
||||||
|
fun displayData(contact: BankContact) = ContactDisplay(
|
||||||
|
id = contact.benefNo,
|
||||||
|
name = contact.benefNickName,
|
||||||
|
realName = contact.benefName,
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
categoryId = contact.benefCategoryId,
|
||||||
|
network = TransferNetwork.BML,
|
||||||
|
bankColor = contact.bankColor,
|
||||||
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
imageHash = contact.customerImgHash,
|
||||||
|
profileId = contact.profileId,
|
||||||
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
canTransfer = true,
|
||||||
|
canEdit = true,
|
||||||
|
canDelete = true
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
|
||||||
|
object BmlDashboardParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all display fields for an account/card row in the accounts list.
|
||||||
|
* Handles both BML CASA accounts and BML prepaid/credit cards.
|
||||||
|
*/
|
||||||
|
fun displayData(account: BankAccount): AccountListDisplay? {
|
||||||
|
if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only
|
||||||
|
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||||
|
return if (isCard) {
|
||||||
|
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
|
balance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
isCard = true,
|
||||||
|
cardBrandIcon = cardBrandIcon(account.accountTypeName),
|
||||||
|
statusLabel = if (isActive) null else account.statusDesc
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
|
balance = listBalance(account)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a display-ready product label for a BML dashboard account or card.
|
||||||
|
*/
|
||||||
|
fun productLabel(raw: String): String {
|
||||||
|
val u = raw.trim().uppercase()
|
||||||
|
return when {
|
||||||
|
u == "SAVINGS ACCOUNT" -> "Savings"
|
||||||
|
u == "CURRENT ACCOUNT" ||
|
||||||
|
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
||||||
|
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
||||||
|
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
||||||
|
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
||||||
|
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
||||||
|
else -> toTitleCase(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */
|
||||||
|
fun listBalance(account: BankAccount): String =
|
||||||
|
"${account.currencyName} ${account.currentBalance}"
|
||||||
|
|
||||||
|
fun cardBrandIcon(productName: String): Int = when {
|
||||||
|
productName.contains("AMEX", ignoreCase = true) ||
|
||||||
|
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
|
||||||
|
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
|
||||||
|
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
|
||||||
|
else -> R.drawable.ic_nav_card
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toTitleCase(input: String): String =
|
||||||
|
input.trim().lowercase().split(" ").joinToString(" ") { word ->
|
||||||
|
word.replaceFirstChar { it.uppercaseChar() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package sh.sar.basedbank.util.bmlapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
|
|
||||||
|
object BmlHistoryParser {
|
||||||
|
|
||||||
|
fun displayData(account: BankAccount): AccountHistoryDisplay {
|
||||||
|
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
|
||||||
|
return AccountHistoryDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
bankPill = "BML",
|
||||||
|
typeLabel = BmlDashboardParser.productLabel(account.accountTypeName),
|
||||||
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
|
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package sh.sar.basedbank.util.fahipayapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
|
||||||
|
object FahipayAccountParser {
|
||||||
|
|
||||||
|
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = account.accountTypeName,
|
||||||
|
balance = "${account.currencyName} ${account.availableBalance}"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package sh.sar.basedbank.util.fahipayapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
object FahipayContactParser {
|
||||||
|
|
||||||
|
fun displayData(contact: BankContact) = ContactDisplay(
|
||||||
|
id = contact.benefNo,
|
||||||
|
name = contact.benefNickName,
|
||||||
|
realName = contact.benefName,
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
categoryId = contact.benefCategoryId,
|
||||||
|
network = TransferNetwork.FAHIPAY,
|
||||||
|
bankColor = contact.bankColor,
|
||||||
|
detail = null, // Fahipay contacts show no detail line
|
||||||
|
imageHash = contact.customerImgHash,
|
||||||
|
profileId = contact.profileId,
|
||||||
|
transferSubtitle = contact.benefAccount,
|
||||||
|
canTransfer = false,
|
||||||
|
canEdit = false,
|
||||||
|
canDelete = false
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package sh.sar.basedbank.util.fahipayapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
|
|
||||||
|
object FahipayHistoryParser {
|
||||||
|
|
||||||
|
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
bankPill = "FP",
|
||||||
|
typeLabel = account.accountTypeName,
|
||||||
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
|
blockedBalance = null
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package sh.sar.basedbank.util.mibapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
|
||||||
|
object MibAccountParser {
|
||||||
|
|
||||||
|
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = productLabel(account.accountTypeName),
|
||||||
|
balance = "${account.currencyName} ${account.availableBalance}"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a display-ready product label for a MIB (Faisanet) account type name.
|
||||||
|
*/
|
||||||
|
fun productLabel(raw: String): String {
|
||||||
|
val u = raw.trim().uppercase()
|
||||||
|
return when {
|
||||||
|
u == "SAVING ACCOUNT" -> "Savings"
|
||||||
|
u == "CURRENT ACCOUNT" -> "Current"
|
||||||
|
else -> raw.trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package sh.sar.basedbank.util.mibapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
object MibContactParser {
|
||||||
|
|
||||||
|
fun displayData(contact: BankContact): ContactDisplay {
|
||||||
|
val network = when (contact.benefType) {
|
||||||
|
"I" -> TransferNetwork.MIB
|
||||||
|
"S" -> TransferNetwork.SWIFT
|
||||||
|
else -> TransferNetwork.LOCAL // "L" and anything else
|
||||||
|
}
|
||||||
|
return ContactDisplay(
|
||||||
|
id = contact.benefNo,
|
||||||
|
name = contact.benefNickName,
|
||||||
|
realName = contact.benefName,
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
categoryId = contact.benefCategoryId,
|
||||||
|
network = network,
|
||||||
|
bankColor = contact.bankColor,
|
||||||
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
imageHash = contact.customerImgHash,
|
||||||
|
profileId = contact.profileId,
|
||||||
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
canTransfer = true,
|
||||||
|
canEdit = true,
|
||||||
|
canDelete = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package sh.sar.basedbank.util.mibapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
|
|
||||||
|
object MibHistoryParser {
|
||||||
|
|
||||||
|
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
bankPill = null, // MIB has no bank pill
|
||||||
|
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
|
||||||
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
|
blockedBalance = null
|
||||||
|
)
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/americanexpress.xml
Normal file
10
app/src/main/res/drawable/americanexpress.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#016FD0"
|
||||||
|
android:pathData="M16.015 14.378c0-.32-.135-.496-.344-.622-.21-.12-.464-.135-.81-.135h-1.543v2.82h.675v-1.027h.72c.24 0 .39.024.478.125.12.13.104.38.104.55v.35h.66v-.555c-.002-.25-.017-.376-.108-.516-.06-.08-.18-.18-.33-.234l.02-.008c.18-.072.48-.297.48-.747zm-.87.407l-.028-.002c-.09.053-.195.058-.33.058h-.81v-.63h.824c.12 0 .24 0 .33.05.098.048.156.147.15.255 0 .12-.045.215-.134.27zM20.297 15.837H19v.6h1.304c.676 0 1.05-.278 1.05-.884 0-.28-.066-.448-.187-.582-.153-.133-.392-.193-.73-.207l-.376-.015c-.104 0-.18 0-.255-.03-.09-.03-.15-.105-.15-.21 0-.09.017-.166.09-.21.083-.046.177-.066.272-.06h1.23v-.602h-1.35c-.704 0-.958.437-.958.84 0 .9.776.855 1.407.87.104 0 .18.015.225.06.046.03.082.106.082.18 0 .077-.035.15-.08.18-.06.053-.15.07-.277.07zM0 0v10.096L.81 8.22h1.75l.225.464V8.22h2.043l.45 1.02.437-1.013h6.502c.295 0 .56.057.756.236v-.23h1.787v.23c.307-.17.686-.23 1.12-.23h2.606l.24.466v-.466h1.918l.254.465v-.466h1.858v3.948H20.87l-.36-.6v.585h-2.353l-.256-.63h-.583l-.27.614h-1.213c-.48 0-.84-.104-1.08-.24v.24h-2.89v-.884c0-.12-.03-.12-.105-.135h-.105v1.036H6.067v-.48l-.21.48H4.69l-.202-.48v.465H2.235l-.256-.624H1.4l-.256.624H0V24h23.786v-7.108c-.27.135-.613.18-.973.18H21.09v-.255c-.21.165-.57.255-.914.255H14.71v-.9c0-.12-.018-.12-.12-.12h-.075v1.022h-1.8v-1.066c-.298.136-.643.15-.928.136h-.214v.915h-2.18l-.54-.617-.57.6H4.742v-3.93h3.61l.518.602.554-.6h2.412c.28 0 .74.03.942.225v-.24h2.177c.202 0 .644.045.903.225v-.24h3.265v.24c.163-.164.508-.24.803-.24h1.89v.24c.194-.15.464-.24.84-.24h1.176V0H0zM21.156 14.955c.004.005.006.012.01.016.01.01.024.01.032.02l-.042-.035zM23.828 13.082h.065v.555h-.065zM23.865 15.03v-.005c-.03-.025-.046-.048-.075-.07-.15-.153-.39-.215-.764-.225l-.36-.012c-.12 0-.194-.007-.27-.03-.09-.03-.15-.105-.15-.21 0-.09.03-.16.09-.204.076-.045.15-.05.27-.05h1.223v-.588h-1.283c-.69 0-.96.437-.96.84 0 .9.78.855 1.41.87.104 0 .18.015.224.06.046.03.076.106.076.18 0 .07-.034.138-.09.18-.045.056-.136.07-.27.07h-1.288v.605h1.287c.42 0 .734-.118.9-.36h.03c.09-.134.135-.3.135-.523 0-.24-.045-.39-.135-.526zM18.597 14.208v-.583h-2.235V16.458h2.235v-.585h-1.57v-.57h1.533v-.584h-1.532v-.51M13.51 8.787h.685V11.6h-.684zM13.126 9.543l-.007.006c0-.314-.13-.5-.34-.624-.217-.125-.47-.135-.81-.135H10.43v2.82h.674v-1.034h.72c.24 0 .39.03.487.12.122.136.107.378.107.548v.354h.677v-.553c0-.25-.016-.375-.11-.516-.09-.107-.202-.19-.33-.237.172-.07.472-.3.472-.75zm-.855.396h-.015c-.09.054-.195.056-.33.056H11.1v-.623h.825c.12 0 .24.004.33.05.09.04.15.128.15.25s-.047.22-.134.266zM15.92 9.373h.632v-.6h-.644c-.464 0-.804.105-1.02.33-.286.3-.362.69-.362 1.11 0 .512.123.833.36 1.074.232.238.645.31.97.31h.78l.255-.627h1.39l.262.627h1.36v-2.11l1.272 2.11h.95l.002.002V8.786h-.684v1.963l-1.18-1.96h-1.02V11.4L18.11 8.744h-1.004l-.943 2.22h-.3c-.177 0-.362-.03-.468-.134-.125-.15-.186-.36-.186-.662 0-.285.08-.51.194-.63.133-.135.272-.165.516-.165zm1.668-.108l.464 1.118v.002h-.93l.466-1.12zM2.38 10.97l.254.628H4V9.393l.972 2.205h.584l.973-2.202.015 2.202h.69v-2.81H6.118l-.807 1.904-.876-1.905H3.343v2.663L2.205 8.787h-.997L.01 11.597h.72l.26-.626h1.39zm-.688-1.705l.46 1.118-.003.002h-.915l.457-1.12zM11.856 13.62H9.714l-.85.923-.825-.922H5.346v2.82H8l.855-.932.824.93h1.302v-.94h.838c.6 0 1.17-.164 1.17-.945l-.006-.003c0-.78-.598-.93-1.128-.93zM7.67 15.853l-.014-.002H6.02v-.557h1.47v-.574H6.02v-.51H7.7l.733.82-.764.824zm2.642.33l-1.03-1.147 1.03-1.108v2.253zm1.553-1.258h-.885v-.717h.885c.24 0 .42.098.42.344 0 .243-.15.372-.42.372zM9.967 9.373v-.586H7.73V11.6h2.237v-.58H8.4v-.564h1.527V9.88H8.4v-.507" />
|
||||||
|
</vector>
|
||||||
58
app/src/main/res/drawable/avd_hide_amounts.xml
Normal file
58
app/src/main/res/drawable/avd_hide_amounts.xml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt">
|
||||||
|
|
||||||
|
<aapt:attr name="android:drawable">
|
||||||
|
<vector
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:width="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:name="strike_through"
|
||||||
|
android:pathData="M3.27,4.27 L19.74,20.74"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeWidth="1.8"
|
||||||
|
android:trimPathEnd="0"/>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:name="eye_mask"
|
||||||
|
android:pathData="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
|
||||||
|
<path
|
||||||
|
android:name="eye"
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
</aapt:attr>
|
||||||
|
|
||||||
|
<target android:name="eye_mask">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="320"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_slow_in"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:valueFrom="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueTo="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueType="pathType"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
<target android:name="strike_through">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="320"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_slow_in"
|
||||||
|
android:propertyName="trimPathEnd"
|
||||||
|
android:valueFrom="0"
|
||||||
|
android:valueTo="1"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
</animated-vector>
|
||||||
52
app/src/main/res/drawable/avd_lock.xml
Normal file
52
app/src/main/res/drawable/avd_lock.xml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt">
|
||||||
|
|
||||||
|
<aapt:attr name="android:drawable">
|
||||||
|
<vector
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:width="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Shackle drawn first (behind body) so it appears to slot into the body.
|
||||||
|
Starts translateY=-4 (open/raised), animates to 0 (locked).
|
||||||
|
-->
|
||||||
|
<group
|
||||||
|
android:name="shackle"
|
||||||
|
android:translateY="-4">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/transparent"
|
||||||
|
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1V10"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeWidth="2.2"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Body on top — covers the shackle legs once they slide inside.
|
||||||
|
Even-odd fill cuts out the keyhole.
|
||||||
|
-->
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
</aapt:attr>
|
||||||
|
|
||||||
|
<target android:name="shackle">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="320"
|
||||||
|
android:interpolator="@android:interpolator/overshoot"
|
||||||
|
android:propertyName="translateY"
|
||||||
|
android:valueFrom="-4"
|
||||||
|
android:valueTo="0"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
</animated-vector>
|
||||||
57
app/src/main/res/drawable/avd_show_amounts.xml
Normal file
57
app/src/main/res/drawable/avd_show_amounts.xml
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<animated-vector
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt">
|
||||||
|
|
||||||
|
<aapt:attr name="android:drawable">
|
||||||
|
<vector
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:width="24dp"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:name="strike_through"
|
||||||
|
android:pathData="M3.27,4.27 L19.74,20.74"
|
||||||
|
android:strokeColor="@android:color/white"
|
||||||
|
android:strokeLineCap="square"
|
||||||
|
android:strokeWidth="1.8"/>
|
||||||
|
|
||||||
|
<group>
|
||||||
|
<clip-path
|
||||||
|
android:name="eye_mask"
|
||||||
|
android:pathData="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"/>
|
||||||
|
<path
|
||||||
|
android:name="eye"
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,4.5C7,4.5 2.73,7.61 1,12c1.73,4.39 6,7.5 11,7.5s9.27,-3.11 11,-7.5c-1.73,-4.39 -6,-7.5 -11,-7.5zM12,17c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM12,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3 -1.34,-3 -3,-3z"/>
|
||||||
|
</group>
|
||||||
|
|
||||||
|
</vector>
|
||||||
|
</aapt:attr>
|
||||||
|
|
||||||
|
<target android:name="eye_mask">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="200"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_linear_in"
|
||||||
|
android:propertyName="pathData"
|
||||||
|
android:valueFrom="M2,4.27 L19.73,22 L22.27,19.46 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueTo="M2,4.27 L2,4.27 L4.54,1.73 L4.54,1.73 L4.54,1 L23,1 L23,23 L1,23 L1,4.27 Z"
|
||||||
|
android:valueType="pathType"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
<target android:name="strike_through">
|
||||||
|
<aapt:attr name="android:animation">
|
||||||
|
<objectAnimator
|
||||||
|
android:duration="200"
|
||||||
|
android:interpolator="@android:interpolator/fast_out_linear_in"
|
||||||
|
android:propertyName="trimPathEnd"
|
||||||
|
android:valueFrom="1"
|
||||||
|
android:valueTo="0"/>
|
||||||
|
</aapt:attr>
|
||||||
|
</target>
|
||||||
|
|
||||||
|
</animated-vector>
|
||||||
7
app/src/main/res/drawable/bg_card_overlay_gradient.xml
Normal file
7
app/src/main/res/drawable/bg_card_overlay_gradient.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:startColor="#00000000"
|
||||||
|
android:endColor="#CC000000"
|
||||||
|
android:angle="270"/>
|
||||||
|
</shape>
|
||||||
BIN
app/src/main/res/drawable/bml_logo_long.png
Normal file
BIN
app/src/main/res/drawable/bml_logo_long.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
10
app/src/main/res/drawable/ic_block.xml
Normal file
10
app/src/main/res/drawable/ic_block.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurface"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM4,12c0,-4.42 3.58,-8 8,-8 1.85,0 3.55,0.63 4.9,1.68L5.68,16.9C4.63,15.55 4,13.85 4,12zM12,20c-1.85,0 -3.55,-0.63 -4.9,-1.68L18.32,7.1C19.37,8.45 20,10.15 20,12c0,4.42 -3.58,8 -8,8z"/>
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_channel_email.xml
Normal file
10
app/src/main/res/drawable/ic_channel_email.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M20,4H4C2.9,4 2,4.9 2,6v12c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2zM20,8l-8,5 -8,-5V6l8,5 8,-5v2z"/>
|
||||||
|
</vector>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user