Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
494a42c1a8
|
||
|
|
9ace8dd724
|
||
|
|
a3449167db
|
||
|
|
1612e21471
|
||
|
|
f02b05b180
|
||
|
|
5abd981096
|
||
|
|
bb76f4e591
|
||
|
|
8795d5f758
|
||
|
|
0d4f0074c7
|
||
|
|
237d25af67
|
||
|
|
a0c515103a
|
||
|
|
56b464b58e
|
||
|
|
9ab7f979fa
|
||
|
|
7a4b7a1712
|
||
|
|
c0944d3809
|
||
|
|
b05ee44715
|
||
|
|
877147959a
|
||
|
|
68583465de
|
||
|
|
1bf63ed55b
|
||
|
|
3350c84a33
|
||
|
|
449c27ced2
|
||
|
|
3c5d3ff883
|
||
|
|
25b5c80c49
|
||
|
|
2b2fd59543
|
||
|
|
b97d0c5a18
|
||
|
|
4ac328cf52
|
||
|
|
c2f473a6a3
|
||
|
|
9f40c56b49
|
||
|
|
0747fbdbfd
|
||
|
|
528e9eeef8
|
||
|
|
0e7f329a4b
|
||
|
|
2082e8fd0c
|
||
|
|
97a0cb218f
|
||
|
|
0158d6dcd8
|
||
|
|
320eaa2ffb
|
||
|
|
1886113ae7
|
||
|
|
41e7bc70e9
|
||
|
|
d786609bd1
|
||
|
|
2246e5929f
|
||
|
|
d0eee817ec
|
||
|
|
af414914c7
|
||
|
|
ec5b791a45
|
||
|
|
dd4aed0f94
|
||
|
|
fd4cdfecac
|
||
|
|
92f5af76e6
|
||
|
|
bc81255b31
|
||
|
|
acd11ef3eb
|
||
|
|
fc778f2a90
|
||
|
|
778bcc4d75
|
||
|
|
97c8033014
|
||
|
|
58d43f33d6
|
||
|
|
af791fc5ad
|
||
|
|
d8ef3a63c6
|
||
|
|
fbbfdd8537
|
||
|
|
169c3c144c
|
||
|
|
71ef7a5b55
|
||
|
|
32563ea398
|
||
|
|
41ce8a65ad
|
||
|
|
c7d3efa64e
|
||
|
|
2688118f52
|
||
|
|
e717360d00
|
||
|
|
2f26599931
|
||
|
|
f8f9d82440
|
||
|
|
a37354fc82
|
||
|
|
66568a464a
|
||
|
|
5210e6207f
|
||
|
|
369c6e4a37
|
||
|
|
fbedc631c5
|
||
|
|
7d6c192a63
|
||
|
|
e7f7590654
|
||
|
|
a048be939c
|
||
|
|
102a986201
|
||
|
|
440db13a0f
|
||
|
|
0dc81fef62
|
||
|
|
7e52b510a1
|
||
|
|
0d7833af5c
|
||
|
|
9e45e36c0a
|
||
|
|
c883b6b521
|
||
|
|
5dc37aadf0
|
||
|
|
1e3cf3420b
|
||
|
|
d98dcfd407
|
||
|
|
3b55fa30a8
|
||
|
|
7f0f2707a1
|
||
|
|
117733b766
|
||
|
|
d769df6f6b
|
||
|
|
caf0db60da
|
||
|
|
b48022d249
|
||
|
|
7574a5e8b0
|
||
|
|
992c8d4364
|
||
|
|
80fd238195
|
||
|
|
0357d7e0bc
|
||
|
|
71dd654edc
|
||
|
|
a75d8e420a
|
||
|
|
ca6ecc4283
|
||
|
|
f59c2be6c4
|
||
|
|
eb019a20a0
|
||
|
|
c356762baa
|
||
|
|
255f43db24
|
||
|
|
01cae559cf
|
||
|
|
015919a4ac
|
||
|
|
93a7c8bbde
|
||
|
|
8f4672f269
|
||
|
|
00e6b40ee0
|
||
|
|
86e1dc0521
|
||
|
|
a90d832dba
|
||
|
|
51c2dff4b2
|
||
|
|
43f3cca2aa
|
||
|
|
0e226d17ae
|
||
|
|
24021d7eeb
|
||
|
|
e997969070
|
||
|
|
3182e14873
|
||
|
|
52d2eb235b
|
||
|
|
ae18a8c6c8
|
||
|
|
a8cd22cbe1
|
||
|
|
281864347e
|
||
|
|
16fd909c7f
|
||
|
|
a95ca0e7a5
|
||
|
|
286a6f845d
|
||
|
|
5b5f776715
|
@@ -27,24 +27,21 @@ jobs:
|
||||
- name: Rename APK
|
||||
run: |
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
APP_NAME="${APP_NAME//\//-}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||
|
||||
- name: Extract release notes
|
||||
run: |
|
||||
echo "No release notes" > release_notes.md
|
||||
# VERSION="${{ gitea.ref_name }}"
|
||||
# VERSION="${VERSION#v}"
|
||||
#
|
||||
# awk -v ver="$VERSION" '
|
||||
# BEGIN { found=0 }
|
||||
# /^## \[/ {
|
||||
# if (found) exit
|
||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
||||
# }
|
||||
# found { print }
|
||||
# ' CHANGELOG.md > release_notes.md
|
||||
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||
|
||||
if [ -f "$CHANGELOG" ]; then
|
||||
cp "$CHANGELOG" release_notes.md
|
||||
else
|
||||
echo "No changelog found at $CHANGELOG"
|
||||
echo "No release notes" > release_notes.md
|
||||
fi
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
@@ -53,7 +50,7 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
||||
run: |
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
APP_NAME="${APP_NAME//\//-}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
TITLE="${APP_NAME} ${TAG}"
|
||||
NOTES_FILE="release_notes.md"
|
||||
@@ -101,12 +98,12 @@ jobs:
|
||||
fi
|
||||
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
APP_NAME="${APP_NAME//\//-}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||
CAPTION="${APP_NAME} ${TAG}"
|
||||
CAPTION="$(printf '%s\n\n%s' "${APP_NAME} ${TAG}" "$(cat release_notes.md)" | head -c 1024)"
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${TG_CHAT_ID}" \
|
||||
-F "document=@${ASSET_PATH}" \
|
||||
-F "caption=${CAPTION}"
|
||||
--form-string "caption=${CAPTION}"
|
||||
|
||||
@@ -16,6 +16,10 @@ local.properties
|
||||
docs/mibapi/tmp
|
||||
docs/bmlapi/tmp
|
||||
docs/fahipayapi/tmp
|
||||
docs/mfaisaapi/tmp
|
||||
docs/dhiraaguapi/tmp
|
||||
docs/ooredooapi/tmp
|
||||
docs/ooredooapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
.kotlin/*
|
||||
|
||||
Generated
+2
-2
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-06-03T08:28:30.389803148Z">
|
||||
<DropdownSelection timestamp="2026-06-13T17:53:06.478193524Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.245:5555;connection=d182cf37" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -12,12 +12,16 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- Existing accounts with MIB, BML, or Fahipay
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank. See [how to get your TOTP seed](docs/thijooree/faq/totpseed/README.md)
|
||||
|
||||
## Download APK
|
||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||
|
||||
## FAQ
|
||||
|
||||
Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md).
|
||||
|
||||
## Privacy
|
||||
|
||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||
@@ -30,6 +34,9 @@ API reverse-engineering notes and app internals are in [`docs/`](docs/README.md)
|
||||
|
||||
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||
|
||||
## Contributing
|
||||
|
||||
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Talk is cheap, send patches.</p>— FFmpeg (@FFmpeg) <a href="https://x.com/FFmpeg/status/1762805900035686805?ref_src=twsrc%5Etfw">February 28, 2024</a></blockquote>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 19
|
||||
versionName = "1.0.19"
|
||||
versionCode = 35
|
||||
versionName = "1.0.34"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
@@ -45,20 +46,24 @@
|
||||
|
||||
<activity
|
||||
android:name=".LockActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.onboarding.OnboardingActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.login.LoginActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.HomeActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
|
||||
@@ -69,6 +74,7 @@
|
||||
|
||||
<activity
|
||||
android:name=".nfc.BmlTapToPayActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.BasedBank" />
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSession
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
@@ -48,6 +49,10 @@ class BasedBankApp : Application() {
|
||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/** Active M-Faisa sessions keyed by loginId (= msisdn). */
|
||||
val mfaisaSessions: MutableMap<String, MfaisaSession> = mutableMapOf()
|
||||
var mfaisaAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||
@@ -110,6 +115,26 @@ class BasedBankApp : Application() {
|
||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||
|
||||
// ─── M-Faisa helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the M-Faisa session for the given account (matched via loginTag = "mfaisa_${msisdn}"). */
|
||||
fun mfaisaSessionFor(account: BankAccount): MfaisaSession? =
|
||||
mfaisaSessions[account.loginTag.removePrefix("mfaisa_")]
|
||||
|
||||
/**
|
||||
* Re-runs `fetchSubscriber` + `doMobileLogin` using the saved credentials for [loginId] and
|
||||
* replaces the cached session. Call this after catching [sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException].
|
||||
* Returns the fresh session, or null if no credentials are saved for that login.
|
||||
*/
|
||||
fun refreshMfaisaSession(loginId: String): MfaisaSession? {
|
||||
val creds = CredentialStore(this).loadMfaisaCredentials(loginId) ?: return null
|
||||
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this)
|
||||
flow.fetchSubscriber(creds.msisdn)
|
||||
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||
mfaisaSessions[loginId] = result.session
|
||||
return result.session
|
||||
}
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
val mibMutex = Mutex()
|
||||
|
||||
|
||||
@@ -279,7 +279,8 @@ class LockActivity : AppCompatActivity() {
|
||||
finish()
|
||||
} else {
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||
if (!hasCredentials) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||
finish()
|
||||
|
||||
@@ -41,7 +41,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||
val securitySet = prefs.getString("security_method", null) != null
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||
|
||||
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
||||
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
||||
|
||||
@@ -7,6 +7,8 @@ import java.util.concurrent.TimeUnit
|
||||
|
||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
||||
/** Browser User-Agent used for BML's web/Cloudflare-fronted endpoints (login, merchant pay page, ACS). */
|
||||
internal val BML_WEB_USER_AGENT = "Mozilla/5.0 (Android ${Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||
|
||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
@@ -14,9 +16,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
|
||||
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
|
||||
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
|
||||
*/
|
||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("accept", "application/json")
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("user-agent", BML_USER_AGENT)
|
||||
.header("authorization", "Bearer ${session.accessToken}")
|
||||
.build()
|
||||
|
||||
@@ -9,6 +9,12 @@ import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
data class BmlCardHistoryResult(
|
||||
val statement: List<BankTransaction>,
|
||||
val outstanding: List<BankTransaction>,
|
||||
val unbilled: List<BankTransaction>
|
||||
)
|
||||
|
||||
class BmlHistoryClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
@@ -70,7 +76,7 @@ class BmlHistoryClient {
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<BankTransaction> {
|
||||
): BmlCardHistoryResult {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
@@ -81,76 +87,56 @@ class BmlHistoryClient {
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
val json = resp.body?.string() ?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||
val result = mutableListOf<BankTransaction>()
|
||||
if (!root.optBoolean("success")) return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
val payload = root.optJSONObject("payload")
|
||||
?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
|
||||
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 outstanding = parseCardArray(
|
||||
payload.optJSONObject("outstanding")?.optJSONArray("CardOutStdAuthDetails"),
|
||||
idPrefix = "auth", accountNumber, accountDisplayName
|
||||
)
|
||||
val unbilled = parseCardArray(
|
||||
payload.optJSONObject("unbilled")?.optJSONArray("CardUnbillTxnDetails"),
|
||||
idPrefix = "unbilled", accountNumber, accountDisplayName
|
||||
)
|
||||
val statement = parseCardArray(
|
||||
payload.optJSONArray("cardstatement"),
|
||||
idPrefix = "stmt", accountNumber, accountDisplayName
|
||||
)
|
||||
|
||||
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"
|
||||
))
|
||||
}
|
||||
}
|
||||
BmlCardHistoryResult(statement, outstanding, unbilled)
|
||||
} catch (_: Exception) { BmlCardHistoryResult(emptyList(), emptyList(), emptyList()) }
|
||||
}
|
||||
|
||||
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() }
|
||||
private fun parseCardArray(
|
||||
arr: org.json.JSONArray?,
|
||||
idPrefix: String,
|
||||
accountNumber: String,
|
||||
accountDisplayName: String
|
||||
): List<BankTransaction> {
|
||||
if (arr == null) return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val item = arr.getJSONObject(i)
|
||||
val ref = item.optString("TranApprCode")
|
||||
BankTransaction(
|
||||
id = "${idPrefix}_${ref.ifBlank { i.toString() }}",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = ref.takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchPendingHistory(
|
||||
|
||||
@@ -27,7 +27,7 @@ class BmlLoginFlow {
|
||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||
private val APP_VERSION = "2.1.44.348"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
private val WEB_USER_AGENT = BML_WEB_USER_AGENT
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.PAGE_ORIGIN
|
||||
import java.security.KeyFactory
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.X509EncodedKeySpec
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.OAEPParameterSpec
|
||||
import javax.crypto.spec.PSource
|
||||
import android.util.Base64
|
||||
|
||||
/**
|
||||
* Pays a BML Merchant Services payment link by card, for merchants that don't have BML Pay
|
||||
* enabled. It performs the same request sequence the link's own card form (Pomelo JS) and the
|
||||
* issuer's 3-D Secure page perform in a browser:
|
||||
*
|
||||
* 1. `GET public-client/credentials/<id>` (auth header: the page's `pomeloJsKey`) → RSA public
|
||||
* key + Pomelo API key.
|
||||
* 2. `POST api.pay.pomelopay.com/bin-lookup` with the PAN, CVV and `YYMM` expiry, each
|
||||
* RSA-OAEP(SHA-1) encrypted with that key → a card `tokenId`.
|
||||
* 3. `POST public-client/transactions/next-action` RATE_OPTIONS, polling while the server says
|
||||
* WAIT, until it returns a `3dsUrl`.
|
||||
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
|
||||
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
|
||||
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
|
||||
* 5. Poll next-action until TRANSACTION_CONFIRMED. A decline after 3-D Secure (e.g. insufficient
|
||||
* funds) doesn't show up there: it's a new `paymentErrorHistory` entry on the transaction,
|
||||
* checked alongside.
|
||||
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
|
||||
* signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
|
||||
* learn they were paid. Without it the card is charged but the merchant never delivers.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
|
||||
* session cookies.
|
||||
*/
|
||||
class BmlMerchantCardPayClient {
|
||||
|
||||
data class Card(
|
||||
val pan: String,
|
||||
val expiryMonth: String, // "07"
|
||||
val expiryYear: String, // "28"
|
||||
val cvv: String,
|
||||
val holderName: String
|
||||
)
|
||||
|
||||
sealed class Result {
|
||||
/** Paid. [merchantNotified] is false when the return to the merchant (step 6) failed. */
|
||||
data class Success(val merchantNotified: Boolean) : Result()
|
||||
data class Failure(val message: String) : Result()
|
||||
}
|
||||
|
||||
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(45, TimeUnit.SECONDS)
|
||||
// Credentials/next-action tolerate okhttp, but the Cloudflare-fronted ACS does not — send a
|
||||
// browser UA on everything (only when the caller didn't set one).
|
||||
.addInterceptor { chain ->
|
||||
val req = chain.request()
|
||||
chain.proceed(
|
||||
if (req.header("User-Agent") == null)
|
||||
req.newBuilder().header("User-Agent", BML_WEB_USER_AGENT).build()
|
||||
else req
|
||||
)
|
||||
}
|
||||
.cookieJar(object : CookieJar {
|
||||
override fun saveFromResponse(url: HttpUrl, newCookies: List<Cookie>) {
|
||||
val list = cookies.getOrPut(url.host) { mutableListOf() }
|
||||
for (c in newCookies) { list.removeAll { it.name == c.name }; list.add(c) }
|
||||
}
|
||||
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||
cookies.values.flatten().filter { it.matches(url) }
|
||||
})
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Runs the whole payment. [otp] returns the current BML token code; it is called again with
|
||||
* `retry = true` if the ACS rejects a code (it can expire between generating and submitting).
|
||||
*/
|
||||
fun pay(page: BmlMerchantTxnClient.PayPage, card: Card, otp: (retry: Boolean) -> String): Result {
|
||||
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
|
||||
val txnId = page.transactionId
|
||||
|
||||
val txnClient = BmlMerchantTxnClient()
|
||||
val browserId = runCatching { txnClient.announceBrowser(txnId) }.getOrNull()
|
||||
// Earlier attempts' declines are already in the history; only newer ones are ours
|
||||
val priorErrors = browserId?.let { id -> runCatching { txnClient.paymentErrors(txnId, id).size }.getOrNull() }
|
||||
|
||||
// 1-2. Credentials, then tokenise the card with Pomelo
|
||||
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
|
||||
val keyInfo = creds.getJSONObject("publicKey")
|
||||
val publicKey = parsePublicKey(keyInfo.getString("publicKeyPem"))
|
||||
val binBody = JSONObject()
|
||||
.put("encryptedCardNumber", encrypt(publicKey, card.pan))
|
||||
.put("encryptedCardSecurityCode", encrypt(publicKey, card.cvv))
|
||||
.put("encryptedCardExpiry", encrypt(publicKey, card.expiryYear + card.expiryMonth))
|
||||
.put("externalId", txnId)
|
||||
.put("cardHolderName", card.holderName)
|
||||
.put("encryptedCardExpiryMonth", card.expiryMonth)
|
||||
.put("encryptedCardExpiryYear", card.expiryYear)
|
||||
.put("encSerialId", keyInfo.getString("publicKeyId"))
|
||||
val binReq = Request.Builder()
|
||||
.url(creds.optString("binLookupUrl").ifBlank { "https://api.pay.pomelopay.com/bin-lookup" })
|
||||
.post(binBody.toString().toRequestBody(JSON))
|
||||
.header("tenant", "bankofmaldives")
|
||||
.header("x-api-key", creds.getString("apiKey"))
|
||||
.header("x-tenant-id", creds.optString("tid"))
|
||||
.build()
|
||||
val bin = execJson(binReq)
|
||||
val tokenId = bin.optString("tokenId").ifBlank { return Result.Failure("Card was not accepted") }
|
||||
|
||||
// 3. Rate options → poll while WAIT → 3-D Secure URL
|
||||
val cardFields = JSONObject()
|
||||
.put("transactionId", txnId)
|
||||
.put("tokenId", tokenId)
|
||||
.put("bin8", bin.optString("bin8"))
|
||||
.put("cardBrand", bin.optString("brand"))
|
||||
for ((from, to) in listOf("issuer" to "cardIssuer", "country" to "cardCountry",
|
||||
"cardCategory" to "cardCategory", "isCommercial" to "isCommercial",
|
||||
"isPrepaid" to "isPrepaid", "isReloadable" to "isReloadable", "paddedPan" to "paddedPan")) {
|
||||
if (bin.has(from) && !bin.isNull(from)) cardFields.put(to, bin.get(from))
|
||||
}
|
||||
var action = nextAction(pk, copy(cardFields).put("action", "RATE_OPTIONS").withBrowserInfo())
|
||||
val resolved = setOf("WAIT", "POLL", "TRANSACTION_CONFIRMED", "TRANSACTION_FAILED")
|
||||
if (action.optString("action") !in resolved && action.optString("3dsUrl").isBlank()) {
|
||||
action = nextAction(pk, copy(cardFields).put("action", "THREEDS").withBrowserInfo())
|
||||
}
|
||||
|
||||
var threeDsUrl: String? = null
|
||||
for (attempt in 0..MAX_POLLS) {
|
||||
when (action.optString("action")) {
|
||||
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||
}
|
||||
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
|
||||
if (threeDsUrl != null) break
|
||||
if (attempt == MAX_POLLS) return Result.Failure("Timed out waiting for the bank")
|
||||
Thread.sleep(POLL_MS)
|
||||
action = poll(pk, txnId)
|
||||
}
|
||||
|
||||
// 4. 3-D Secure challenge (handles the authenticator channel + TOTP)
|
||||
runThreeDs(threeDsUrl!!, otp)?.let { return it }
|
||||
|
||||
// 5. Wait for the gateway's verdict to reach BML
|
||||
repeat(MAX_POLLS * 2) {
|
||||
when (poll(pk, txnId).optString("action")) {
|
||||
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||
}
|
||||
if (browserId != null && priorErrors != null) {
|
||||
runCatching { txnClient.paymentErrors(txnId, browserId) }.getOrNull()
|
||||
?.drop(priorErrors)?.lastOrNull()
|
||||
?.let { return Result.Failure(it.message.ifBlank { "The bank declined the payment" }) }
|
||||
}
|
||||
Thread.sleep(POLL_MS / 2)
|
||||
}
|
||||
return Result.Failure("Payment status unknown — check with the merchant before retrying")
|
||||
}
|
||||
|
||||
/** The payment went through: return to the merchant, then report success either way. */
|
||||
private fun confirmed(txnId: String) = Result.Success(merchantNotified = returnToMerchant(txnId))
|
||||
|
||||
/**
|
||||
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
|
||||
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
|
||||
* and on to its receipt page. Some merchants' callback page instead auto-submits a form on
|
||||
* load (Ooredoo's posts the result on to its own site), so those forms are submitted too.
|
||||
* Retries a couple of times; true when the chain ended on a 2xx page.
|
||||
*/
|
||||
private fun returnToMerchant(txnId: String): Boolean {
|
||||
repeat(RETURN_ATTEMPTS) { attempt ->
|
||||
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
|
||||
val ok = runCatching {
|
||||
var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
|
||||
for (hop in 0..MAX_AUTO_SUBMITS) {
|
||||
val (code, html, url) = client.newCall(request).execute().use {
|
||||
Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
|
||||
}
|
||||
if (code !in 200..299) return@runCatching false
|
||||
// A page that only exists to post itself onward, like the ACS's own hops
|
||||
if (hop == MAX_AUTO_SUBMITS || !AUTO_SUBMIT.containsMatchIn(html)) return@runCatching true
|
||||
val form = AcsForm.parse(html, url) ?: return@runCatching true
|
||||
request = browserNav(form.toRequest().newBuilder())
|
||||
}
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
if (ok) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun browserNav(builder: Request.Builder): Request = builder
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.build()
|
||||
|
||||
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
|
||||
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
|
||||
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
|
||||
// issuer's ACS. The ACS's own channel/OTP forms carry no action attribute — their JS posts
|
||||
// back to this same creq URL — so it is the fallback action for everything that follows.
|
||||
var form = AcsForm.parse(execText(get(threeDsUrl)), null)
|
||||
?: return Result.Failure("Couldn't start card authentication")
|
||||
val acsUrl = form.action
|
||||
var html = execText(form.toRequest())
|
||||
|
||||
// Channel picker (Mobile / Email / Authenticator). The BML token is the "token" channel.
|
||||
if (html.contains("name=\"destValue\"")) {
|
||||
form = AcsForm.parse(html, acsUrl) ?: return Result.Failure("Unexpected authentication page")
|
||||
form.fields["destValue"] = "token"
|
||||
form.fields["selectChannel"] = "token"
|
||||
form.fields["authMethod"] = "OOB"
|
||||
form.fields["otpDest"] = ""
|
||||
form.fields["formReqType"] = "SUBMIT"
|
||||
html = execText(form.toRequest())
|
||||
}
|
||||
|
||||
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
|
||||
// A rejected code comes back as the OTP page again with "The OTP code you entered is
|
||||
// incorrect Please try again."
|
||||
var retry = false
|
||||
for (attempt in 0..1) {
|
||||
form = AcsForm.parse(html, acsUrl) ?: break
|
||||
if (!form.fields.containsKey("otpValue")) break
|
||||
form.fields["otpValue"] = otp(retry)
|
||||
form.fields["formReqType"] = "SUBMIT"
|
||||
html = execText(form.toRequest())
|
||||
if (!otpRejected(html)) break
|
||||
retry = true
|
||||
}
|
||||
if (otpRejected(html)) return Result.Failure("The bank rejected the BML token code. Check the phone's clock and try again.")
|
||||
// On success the ACS returns an auto-posting form to the gateway; follow it (and the
|
||||
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
|
||||
repeat(3) {
|
||||
val next = AcsForm.parse(html, acsUrl) ?: return null
|
||||
if (next.fields.keys.none { it == "cres" || it == "order.id" }) return null
|
||||
html = execText(next.toRequest())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
|
||||
(html.contains("incorrect", true) || html.contains("expired", true))
|
||||
|
||||
// ── next-action helpers ──────────────────────────────────────────────────
|
||||
|
||||
private fun nextAction(pk: String, body: JSONObject): JSONObject =
|
||||
execJson(Request.Builder()
|
||||
.url("$API_BASE/public-client/transactions/next-action")
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("Authorization", pk)
|
||||
.build())
|
||||
|
||||
private fun poll(pk: String, txnId: String): JSONObject =
|
||||
nextAction(pk, JSONObject().put("action", "POLL").put("transactionId", txnId))
|
||||
|
||||
private fun JSONObject.withBrowserInfo(): JSONObject = this
|
||||
.put("javaEnabled", false).put("javascriptEnabled", true)
|
||||
.put("language", "en-US").put("colorDepth", 24)
|
||||
.put("screenHeight", 1850).put("screenWidth", 1080)
|
||||
.put("tz", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / -60000)
|
||||
.put("userAgent", "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile)")
|
||||
|
||||
private fun copy(o: JSONObject) = JSONObject(o.toString())
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun get(url: String) = Request.Builder().url(url).build()
|
||||
|
||||
private fun getJson(url: String, auth: String): JSONObject =
|
||||
execJson(Request.Builder().url(url).header("Authorization", auth).header("Accept", "application/json").build())
|
||||
|
||||
private fun execJson(request: Request): JSONObject = client.newCall(request).execute().use { r ->
|
||||
val text = r.body?.string().orEmpty()
|
||||
if (!r.isSuccessful) throw Exception("Request failed (HTTP ${r.code})")
|
||||
if (text.isBlank()) JSONObject() else JSONObject(text)
|
||||
}
|
||||
|
||||
private fun execText(request: Request): String = client.newCall(request).execute().use { r ->
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
// ── RSA-OAEP(SHA-1), matching the Pomelo JS crypto.subtle config ──────────
|
||||
|
||||
private fun parsePublicKey(pem: String): java.security.PublicKey {
|
||||
val der = Base64.decode(pem
|
||||
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||
.replace("-----END PUBLIC KEY-----", "")
|
||||
.replace(Regex("\\s"), ""), Base64.DEFAULT)
|
||||
return KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(der))
|
||||
}
|
||||
|
||||
private fun encrypt(key: java.security.PublicKey, value: String): String {
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding")
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, OAEPParameterSpec(
|
||||
"SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT))
|
||||
return Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/**
|
||||
* One `application/x-www-form-urlencoded` form scraped from an ACS HTML page: its POST target
|
||||
* plus every `<input>` name/value. [fields] is mutable so the caller can fill in the chosen
|
||||
* channel and the OTP before re-submitting.
|
||||
*/
|
||||
private class AcsForm(val action: String, val fields: MutableMap<String, String>) {
|
||||
fun toRequest(): Request {
|
||||
val body = FormBody.Builder()
|
||||
for ((k, v) in fields) body.add(k, v)
|
||||
return Request.Builder().url(action).post(body.build()).build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val FORM = Regex("<form\\b[^>]*>", RegexOption.IGNORE_CASE)
|
||||
private val ACTION = Regex("action\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
|
||||
private val INPUT = Regex("<input\\b[^>]*>", RegexOption.IGNORE_CASE)
|
||||
private val NAME = Regex("name\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
|
||||
private val VALUE = Regex("value\\s*=\\s*[\"']([^\"']*)[\"']", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* The first `<form>` and its inputs. The form's `action` is used when present;
|
||||
* otherwise [defaultAction] (the ACS pages set it via JS to the current creq URL).
|
||||
* Null only when there is no form, or no action at all.
|
||||
*/
|
||||
fun parse(html: String, defaultAction: String?): AcsForm? {
|
||||
val form = FORM.find(html) ?: return null
|
||||
val action = ACTION.find(form.value)?.groupValues?.get(1)?.let { unescape(it) }
|
||||
?: defaultAction ?: return null
|
||||
val fields = linkedMapOf<String, String>()
|
||||
for (m in INPUT.findAll(html)) {
|
||||
val name = NAME.find(m.value)?.groupValues?.get(1) ?: continue
|
||||
fields[unescape(name)] = unescape(VALUE.find(m.value)?.groupValues?.get(1) ?: "")
|
||||
}
|
||||
return AcsForm(action, fields)
|
||||
}
|
||||
|
||||
private fun unescape(s: String) = s
|
||||
.replace("&", "&").replace(""", "\"")
|
||||
.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private const val POLL_MS = 5_000L
|
||||
private const val MAX_POLLS = 10
|
||||
private const val RETURN_ATTEMPTS = 3
|
||||
private const val RETURN_RETRY_MS = 2_000L
|
||||
/** Auto-submitting merchant pages followed on the way back; Ooredoo has one. */
|
||||
private const val MAX_AUTO_SUBMITS = 2
|
||||
/** `<body onload="document.forms['x'].submit()">` and the like. */
|
||||
private val AUTO_SUBMIT = Regex("""onload\s*=\s*("[^"]*|'[^']*)\.submit\(\)""", RegexOption.IGNORE_CASE)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
|
||||
* e.g. the bill links Fenaka sends. Merchants with BML Pay enabled get their QR's text fetched so it
|
||||
* can go through the regular BML QR payment flow; card-only merchants are paid by
|
||||
* [BmlMerchantCardPayClient] instead — [fetchPayPage] tells the two apart.
|
||||
*/
|
||||
class BmlMerchantTxnClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/** What the payment page knows about a transaction, from its embedded `window.appData`. */
|
||||
data class PayPage(
|
||||
val transactionId: String,
|
||||
val merchantName: String,
|
||||
val merchantAddress: String,
|
||||
/** Major units (the page's amounts are in cents). */
|
||||
val amount: Double,
|
||||
val currency: String,
|
||||
val state: String,
|
||||
/** BML Pay (`bml_mpos`) is offered: pay through [fetchQrPayload] and the QR flow. */
|
||||
val supportsBmlPay: Boolean,
|
||||
/** Card entry (MPGS via Pomelo) is offered: pay with [BmlMerchantCardPayClient]. */
|
||||
val supportsCard: Boolean,
|
||||
/** `pk_production_…` key the page's card form authenticates with. */
|
||||
val pomeloKey: String?
|
||||
) {
|
||||
val isPaid get() = state == "CONFIRMED"
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads `/<id>/paynow`. The page is server-rendered with everything inline: the transaction,
|
||||
* the merchant, `availableProviders` (lists `bml_mpos` when BML Pay is enabled — empty for
|
||||
* card-only merchants) and the card form's `pomeloJsKey` / `pomeloJsProviders`.
|
||||
*/
|
||||
fun fetchPayPage(transactionId: String): PayPage {
|
||||
val request = Request.Builder()
|
||||
.url("$PAGE_ORIGIN/$transactionId/paynow")
|
||||
// The page host is behind Cloudflare, which 403s non-browser User-Agents.
|
||||
.header("User-Agent", BML_WEB_USER_AGENT)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.header("Accept-Language", "en-US,en;q=0.9")
|
||||
.build()
|
||||
val html = client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Exception("Payment page failed (HTTP ${response.code})")
|
||||
response.body?.string().orEmpty()
|
||||
}
|
||||
val start = html.indexOf(APP_DATA_PREFIX).takeIf { it >= 0 }
|
||||
?.let { it + APP_DATA_PREFIX.length } ?: throw Exception("Payment page has no app data")
|
||||
val end = html.indexOf("</script>", start).takeIf { it >= 0 } ?: throw Exception("Payment page has no app data")
|
||||
val data = JSONObject(html.substring(start, end))
|
||||
|
||||
val txn = data.optJSONObject("transaction") ?: throw Exception("Payment page has no transaction")
|
||||
val merchant = data.optJSONObject("merchant")
|
||||
val providers = data.optJSONArray("availableProviders") ?: JSONArray()
|
||||
val bmlPay = (0 until providers.length()).any {
|
||||
val p = providers.optJSONObject(it)
|
||||
p?.optString("value") == PROVIDER_BML && p.optBoolean("enabled", true)
|
||||
}
|
||||
val pomeloProviders = data.optJSONArray("pomeloJsProviders") ?: JSONArray()
|
||||
val pomeloKey = data.optString("pomeloJsKey").ifBlank { null }
|
||||
val card = pomeloKey != null && (0 until pomeloProviders.length()).any { pomeloProviders.optString(it) == "mpgs" }
|
||||
val cents = if (txn.isNull("payAmount")) txn.optLong("amount") else txn.optLong("payAmount")
|
||||
|
||||
return PayPage(
|
||||
transactionId = transactionId,
|
||||
merchantName = merchant?.optString("tradingName")?.ifBlank { null }
|
||||
?: merchant?.optString("registeredName").orEmpty(),
|
||||
merchantAddress = listOfNotNull(
|
||||
merchant?.optString("address1")?.ifBlank { null },
|
||||
merchant?.optString("city")?.ifBlank { null }
|
||||
).joinToString(", "),
|
||||
amount = cents / 100.0,
|
||||
currency = txn.optString("payCurrency").ifBlank { txn.optString("currency", "MVR") },
|
||||
state = txn.optString("state"),
|
||||
supportsBmlPay = bmlPay,
|
||||
supportsCard = card,
|
||||
pomeloKey = pomeloKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transaction's EMV QR payload (`vendorQrCode`).
|
||||
*
|
||||
* A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the
|
||||
* page itself sends need no auth and return the full transaction:
|
||||
* - on load, `activeBrowserId` (`<id>_<epoch millis>`);
|
||||
* - on picking "BML" as the payment method, `provider: bml_mpos`.
|
||||
*
|
||||
* A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects
|
||||
* one. Links already opened with BML chosen return it from the first.
|
||||
*/
|
||||
fun fetchQrPayload(transactionId: String): String {
|
||||
val browserId = JSONObject()
|
||||
.put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")
|
||||
patch(transactionId, browserId).vendorQrCode()?.let { return it }
|
||||
// Whether the provider PATCH returns the QR itself or it is generated a moment later has
|
||||
// not been observed, so re-read a few times before giving up. Re-reads use the load PATCH:
|
||||
// each provider PATCH counts as another payment attempt.
|
||||
var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML))
|
||||
repeat(3) {
|
||||
txn.vendorQrCode()?.let { return it }
|
||||
Thread.sleep(1000)
|
||||
txn = patch(transactionId, browserId)
|
||||
}
|
||||
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
|
||||
}
|
||||
|
||||
/**
|
||||
* The PATCHes the page sends on load: register this "browser" and clear any FX selection.
|
||||
* Returns the browser id, for [paymentErrors].
|
||||
*/
|
||||
fun announceBrowser(transactionId: String): String {
|
||||
val browserId = "${transactionId}_${System.currentTimeMillis()}"
|
||||
patch(transactionId, JSONObject().put("activeBrowserId", browserId))
|
||||
patch(transactionId, JSONObject().put("fx", "reset"))
|
||||
return browserId
|
||||
}
|
||||
|
||||
/**
|
||||
* The transaction's failed payment attempts, oldest first, read with the page's load PATCH
|
||||
* as [browserId]. A declined card (e.g. `INSUFFICIENT_FUNDS`) lands here while the
|
||||
* transaction stays payable — `state` doesn't change, `hasError` turns true.
|
||||
*/
|
||||
fun paymentErrors(transactionId: String, browserId: String): List<PaymentError> {
|
||||
val history = patch(transactionId, JSONObject().put("activeBrowserId", browserId))
|
||||
.optJSONArray("paymentErrorHistory") ?: return emptyList()
|
||||
return (0 until history.length()).mapNotNull { history.optJSONObject(it) }.map {
|
||||
PaymentError(
|
||||
code = it.optString("code"),
|
||||
message = it.optString("customerVisibleDescription").ifBlank { it.optString("reason") }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One entry of `paymentErrorHistory`: the gateway's code and the wording BML shows for it. */
|
||||
data class PaymentError(val code: String, val message: String)
|
||||
|
||||
private fun patch(transactionId: String, body: JSONObject): JSONObject {
|
||||
val request = Request.Builder()
|
||||
.url("$API_BASE/transactions/$transactionId")
|
||||
.patch(body.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", PAGE_ORIGIN)
|
||||
.header("Referer", "$PAGE_ORIGIN/")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val text = response.body?.string().orEmpty()
|
||||
if (!response.isSuccessful || !text.trimStart().startsWith("{"))
|
||||
throw Exception("Transaction lookup failed (HTTP ${response.code})")
|
||||
JSONObject(text)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup
|
||||
* already rejects a paid or expired QR with its own message. `isNull` first — `optString`
|
||||
* turns a JSON null into the string "null".
|
||||
*/
|
||||
private fun JSONObject.vendorQrCode(): String? =
|
||||
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
|
||||
|
||||
companion object {
|
||||
internal const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
|
||||
internal const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
|
||||
private const val APP_DATA_PREFIX = "window.appData = "
|
||||
private const val PROVIDER_BML = "bml_mpos"
|
||||
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
|
||||
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
|
||||
|
||||
/** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */
|
||||
fun parseTransactionId(input: String): String? {
|
||||
val s = input.trim()
|
||||
val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1)
|
||||
return id?.lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
||||
val currency: String
|
||||
)
|
||||
|
||||
/**
|
||||
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
|
||||
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
|
||||
* parse failures stay plain exceptions so the generic message is used for those instead.
|
||||
*/
|
||||
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
|
||||
|
||||
data class BmlQrPayResult(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import android.util.Base64
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -10,17 +11,27 @@ class BmlQrPayClient {
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Resolves a BML QR URL to merchant details.
|
||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
||||
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||
* `35` → `20` → `01` reference.
|
||||
*
|
||||
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
|
||||
* resolves too, so this is parity rather than a requirement.
|
||||
*/
|
||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
|
||||
val key = Base64.encodeToString(
|
||||
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
val request = bmlApiRequest(session,
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw Exception("No response")
|
||||
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
|
||||
if (!body.trimStart().startsWith("{"))
|
||||
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success"))
|
||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||
throw BmlQrPayLookupException(
|
||||
json.optInt("code"),
|
||||
json.optString("message").ifBlank { "Lookup failed" })
|
||||
val payload = json.getJSONObject("payload")
|
||||
val addr2 = payload.optString("narrative2").trim()
|
||||
val addr3 = payload.optString("narrative3").trim()
|
||||
|
||||
@@ -71,7 +71,8 @@ class BmlValidateClient {
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
// BML's MIB verify endpoint doesn't return the MIB account's currency.
|
||||
currency = "",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package sh.sar.basedbank.api.dhiraagu
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
|
||||
* paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
|
||||
* BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
|
||||
* link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread.
|
||||
*/
|
||||
class DhiraaguPaymentClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Creates the reload order for [number] and the BML transaction paying for it: cart →
|
||||
* merchant (the BML gateway entry) → payment → BML transaction. [amount] is the whole MVR
|
||||
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
|
||||
* wording when a step is refused.
|
||||
*/
|
||||
fun createReloadTransaction(number: String, amount: Int): String {
|
||||
val topupNonce = pageNonce("$BASE/services/easy-topup")
|
||||
val gst = gstOf(amount)
|
||||
val cart = api("cart", "recharge", topupNonce, JSONObject()
|
||||
.put("formId", FORM_RELOAD)
|
||||
.put("serviceNumber", number)
|
||||
.put("amount", amount)
|
||||
.put("amountGST", gst.toDouble())
|
||||
.put("amountRecharge", (BigDecimal(amount) - gst).toDouble())
|
||||
.put("gstRate", GST_RATE)
|
||||
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||
.put("customerId", "").put("customerCode", "")
|
||||
.put("version", 2))
|
||||
return payCart(FORM_RELOAD, cart, BigDecimal(amount))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the bill payment order for postpaid [number] and the BML transaction paying for
|
||||
* it: lookup (for the billing account) → cart → merchant → payment → BML transaction.
|
||||
* [amount] is MVR, up to 2 decimal places. Returns the 24-hex BML transaction id. Throws
|
||||
* with Dhiraagu's wording when a step is refused.
|
||||
*/
|
||||
fun createBillPayTransaction(number: String, amount: BigDecimal): String {
|
||||
val easyPayNonce = pageNonce("$BASE/services/easy-pay")
|
||||
// The cart needs the billing account the number belongs to, which only the lookup gives
|
||||
val info = call("dhiraaguIO", "infoUnlisted", easyPayNonce, JSONObject().put("number", number))
|
||||
if (info.optJSONArray("serviceDetails")?.optJSONObject(0)?.optString("prepaidIndicator") == "Y") {
|
||||
throw Exception("Prepaid number is not allowed.")
|
||||
}
|
||||
val accountNumber = info.optString("accountNumber").ifBlank { throw Exception("Invalid account/service number") }
|
||||
|
||||
// The page refuses some account statuses and customer types before ordering; so do we
|
||||
val rules = runCatching { get("setting", "bill", easyPayNonce).getJSONObject("resp").getJSONObject("settingAppJson1") }.getOrNull()
|
||||
val status = info.optString("accountStatus")
|
||||
if (rules != null && status in rules.blockedValues("accountStatus")) {
|
||||
throw Exception("Dhiraagu can't accept payment for this service. Contact Dhiraagu customer service. [Account Status: $status]")
|
||||
}
|
||||
val customerType = info.optString("customerType")
|
||||
if (rules != null && customerType in rules.blockedValues("customerType")) {
|
||||
throw Exception("The number is not allowed. [Customer Type: $customerType]")
|
||||
}
|
||||
|
||||
val cart = api("cart", "easyPay", easyPayNonce, JSONObject()
|
||||
.put("formId", FORM_BILL)
|
||||
.put("serviceNumber", number)
|
||||
.put("accountNumber", accountNumber)
|
||||
.put("amount", money(amount))
|
||||
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||
.put("billRef", "")
|
||||
.put("billType", if (info.optString("type") == BILL_WRITE_OFF) BILL_WRITE_OFF else BILL_PAYMENT))
|
||||
return payCart(FORM_BILL, cart, amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment page's half, shared by every form: picks the BML gateway, creates the payment
|
||||
* for [cart] and has Dhiraagu create the BML transaction. Returns its 24-hex id.
|
||||
*/
|
||||
private fun payCart(formId: Int, cart: JSONObject, amount: BigDecimal): String {
|
||||
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
|
||||
|
||||
// The payment page carries its own nonce, used for the rest of the order
|
||||
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
|
||||
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
|
||||
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
|
||||
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
|
||||
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
|
||||
|
||||
val payment = api("payment", "create", paymentNonce, JSONObject()
|
||||
.put("formId", formId)
|
||||
.put("cartId", cartId)
|
||||
.put("gatewayId", GATEWAY_BML)
|
||||
.put("dhiraaguPayNumber", "")
|
||||
.put("amount", money(amount))
|
||||
.put("paymentMerchantId", bml.getString("merchantId"))
|
||||
.put("memberId", "").put("tokenize", "").put("paymentType", "")
|
||||
.put("recurringFrequency", "").put("bmlTokenId", ""))
|
||||
val paymentId = payment.optString("paymentId").ifBlank { throw Exception("Dhiraagu didn't create the payment") }
|
||||
|
||||
val txn = api("bml", "createV2", paymentNonce, JSONObject().put("paymentId", paymentId))
|
||||
return TXN_URL.find(txn.optString("url"))?.groupValues?.get(1)
|
||||
?: throw Exception("BML didn't create the transaction")
|
||||
}
|
||||
|
||||
private fun money(amount: BigDecimal) =
|
||||
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||
|
||||
/** The `val` list of a `setting` rule, e.g. `{"accountStatus":{"val":["F"]}}`. */
|
||||
private fun JSONObject.blockedValues(rule: String): Set<String> {
|
||||
val vals = optJSONObject(rule)?.optJSONArray("val") ?: return emptySet()
|
||||
return (0 until vals.length()).map { vals.optString(it) }.toSet()
|
||||
}
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
|
||||
private fun pageNonce(url: String): String =
|
||||
NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
|
||||
|
||||
private fun page(url: String): String = client.newCall(
|
||||
Request.Builder().url(url)
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
.build()
|
||||
).execute().use { r ->
|
||||
if (r.code in 500..599) throw BankServerException("Dhiraagu")
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
|
||||
private fun api(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
|
||||
call(sub, act, nonce, body).getJSONObject("resp")
|
||||
|
||||
private fun apiList(sub: String, act: String, nonce: String, body: JSONObject): JSONArray =
|
||||
call(sub, act, nonce, body).getJSONArray("resp")
|
||||
|
||||
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
|
||||
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
|
||||
send(sub, act, nonce, body.toString().toRequestBody(JSON))
|
||||
|
||||
/** GETs from `sdk-dhr-webapi.ashx` (the settings calls); throws unless `respStatus` is OK. */
|
||||
private fun get(sub: String, act: String, nonce: String): JSONObject = send(sub, act, nonce, null)
|
||||
|
||||
private fun send(sub: String, act: String, nonce: String, body: okhttp3.RequestBody?): JSONObject {
|
||||
val text = client.newCall(
|
||||
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
|
||||
.apply { if (body != null) post(body) }
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Origin", BASE)
|
||||
.header("nonce", nonce)
|
||||
.build()
|
||||
).execute().use { r ->
|
||||
if (r.code in 500..599) throw BankServerException("Dhiraagu")
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||
throw Exception("Unexpected response from Dhiraagu")
|
||||
}
|
||||
if (obj.optString("respStatus") != "OK") {
|
||||
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BASE = "https://www.dhiraagu.com.mv"
|
||||
private const val API = "$BASE/api/sdk-dhr-webapi.ashx"
|
||||
private const val WEBSITE_ID = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"
|
||||
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val NONCE = Regex("""var nonce = "([^"]+)"""")
|
||||
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||
|
||||
/** Easy Pay's (bill payment) form id across cart / merchant / payment. */
|
||||
private const val FORM_BILL = 1
|
||||
/** Easy TopUp's form id across cart / merchant / payment. */
|
||||
private const val FORM_RELOAD = 2
|
||||
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
|
||||
private const val GATEWAY_BML = 1
|
||||
private const val GST_RATE = 0.08
|
||||
|
||||
/** Easy Pay's `billType`s; the lookup's `type` says which applies. */
|
||||
private const val BILL_PAYMENT = "BillPayment"
|
||||
private const val BILL_WRITE_OFF = "writeOffPayments"
|
||||
|
||||
/**
|
||||
* The GST inside a GST-inclusive reload [amount], as the page works it out:
|
||||
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
|
||||
*/
|
||||
fun gstOf(amount: Int): BigDecimal {
|
||||
val rate = BigDecimal(GST_RATE.toString())
|
||||
return (BigDecimal(amount) * rate).divide(BigDecimal.ONE + rate, 2, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody
|
||||
import okio.Buffer
|
||||
|
||||
/** Form-body helpers shared by the Fahipay POST endpoints (login, OTP, payments). */
|
||||
internal object FahipayForm {
|
||||
|
||||
/** The `device[...]` fields every Fahipay POST carries. */
|
||||
fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||
"device[available]" to "true",
|
||||
"device[platform]" to "Android",
|
||||
"device[uuid]" to deviceUuid,
|
||||
"device[model]" to Build.MODEL,
|
||||
"device[manufacturer]" to Build.MANUFACTURER,
|
||||
"device[isVirtual]" to "false",
|
||||
"device[serial]" to "unknown"
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
|
||||
* which is what the Fahipay server requires.
|
||||
*/
|
||||
fun body(vararg parts: Pair<String, String>): RequestBody {
|
||||
val boundary = java.util.UUID.randomUUID().toString()
|
||||
val buf = Buffer()
|
||||
for ((name, value) in parts) {
|
||||
val valueBytes = value.toByteArray(Charsets.UTF_8)
|
||||
buf.writeUtf8("--$boundary\r\n")
|
||||
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
|
||||
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
|
||||
buf.writeUtf8("\r\n")
|
||||
buf.write(valueBytes)
|
||||
buf.writeUtf8("\r\n")
|
||||
}
|
||||
buf.writeUtf8("--$boundary--\r\n")
|
||||
val snapshot = buf.readByteString()
|
||||
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
|
||||
return object : RequestBody() {
|
||||
override fun contentType() = mediaType
|
||||
override fun contentLength() = snapshot.size.toLong()
|
||||
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,8 @@ import android.os.Build
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.Buffer
|
||||
import org.json.JSONObject
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -74,14 +71,14 @@ class FahipayLoginFlow {
|
||||
*/
|
||||
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
|
||||
initSession()
|
||||
val body = buildFormBody(
|
||||
val body = FahipayForm.body(
|
||||
"email" to idCard,
|
||||
"password" to password,
|
||||
"grant_type" to "auth_id",
|
||||
"lang" to "en",
|
||||
"version" to "2.0.0",
|
||||
"platform" to "BasedBank",
|
||||
*deviceParts(deviceUuid)
|
||||
"platform" to "thijooree",
|
||||
*FahipayForm.deviceParts(deviceUuid)
|
||||
)
|
||||
|
||||
val resp = client.newCall(
|
||||
@@ -109,15 +106,15 @@ class FahipayLoginFlow {
|
||||
* Returns authId.
|
||||
*/
|
||||
fun verifyTotp(code: String, deviceUuid: String): String {
|
||||
val body = buildFormBody(
|
||||
val body = FahipayForm.body(
|
||||
"code" to code,
|
||||
"channel" to "totp",
|
||||
"action" to "login",
|
||||
"grant_type" to "auth_id",
|
||||
"lang" to "en",
|
||||
"version" to "2.0.0",
|
||||
"platform" to "BasedBank",
|
||||
*deviceParts(deviceUuid)
|
||||
"platform" to "thijooree",
|
||||
*FahipayForm.deviceParts(deviceUuid)
|
||||
)
|
||||
|
||||
val resp = client.newCall(
|
||||
@@ -138,42 +135,6 @@ class FahipayLoginFlow {
|
||||
?: throw Exception("No authID in OTP response")
|
||||
}
|
||||
|
||||
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||
"device[available]" to "true",
|
||||
"device[platform]" to "Android",
|
||||
"device[uuid]" to deviceUuid,
|
||||
"device[model]" to Build.MODEL,
|
||||
"device[manufacturer]" to Build.MANUFACTURER,
|
||||
"device[isVirtual]" to "false",
|
||||
"device[serial]" to "unknown"
|
||||
)
|
||||
|
||||
/**
|
||||
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
|
||||
* which is what the Fahipay server requires.
|
||||
*/
|
||||
private fun buildFormBody(vararg parts: Pair<String, String>): RequestBody {
|
||||
val boundary = java.util.UUID.randomUUID().toString()
|
||||
val buf = Buffer()
|
||||
for ((name, value) in parts) {
|
||||
val valueBytes = value.toByteArray(Charsets.UTF_8)
|
||||
buf.writeUtf8("--$boundary\r\n")
|
||||
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
|
||||
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
|
||||
buf.writeUtf8("\r\n")
|
||||
buf.write(valueBytes)
|
||||
buf.writeUtf8("\r\n")
|
||||
}
|
||||
buf.writeUtf8("--$boundary--\r\n")
|
||||
val snapshot = buf.readByteString()
|
||||
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
|
||||
return object : RequestBody() {
|
||||
override fun contentType() = mediaType
|
||||
override fun contentLength() = snapshot.size.toLong()
|
||||
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun generateDeviceUuid(): String {
|
||||
val bytes = ByteArray(8)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Pays a phone number from the Fahipay wallet: Ooredoo Raastas, Ooredoo bill pay, Dhiraagu
|
||||
* reload and Dhiraagu bill pay. All four are the same POST, only the path differs — see
|
||||
* `docs/fahipayapi/09-payments.md`.
|
||||
*/
|
||||
class FahipayPaymentClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* A payment the server accepted. [message] is its wording, e.g. "Transaction successful." or,
|
||||
* for Dhiraagu bill pay, "Transaction will be processed shortly.". [transactionId] (`tid`) is
|
||||
* only returned by the reload / Raastas endpoints.
|
||||
*/
|
||||
data class Result(val message: String, val transactionId: String?)
|
||||
|
||||
/**
|
||||
* POSTs the payment to [path] (e.g. `actions/payment/ooredoo/recharge/`). [amount] is sent as
|
||||
* typed — the caller checks the service's limits first. Returns on success; throws with the
|
||||
* server's message when it refuses, [BankServerException] on a 5xx, and IOException when the
|
||||
* request doesn't get through. Blocking — call from IO.
|
||||
*/
|
||||
fun pay(session: FahipaySession, path: String, number: String, amount: String, deviceUuid: String): Result {
|
||||
val body = FahipayForm.body(
|
||||
"number" to number,
|
||||
"amount" to amount,
|
||||
"lang" to "en",
|
||||
"version" to "2.0.2",
|
||||
"build" to "329",
|
||||
"platform" to "thijooree",
|
||||
*FahipayForm.deviceParts(deviceUuid)
|
||||
)
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/$path")
|
||||
.post(body)
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("User-Agent", UA)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string().orEmpty()
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
|
||||
val obj = try { JSONObject(json) } catch (_: Exception) {
|
||||
throw Exception("Unexpected response from Fahipay (HTTP $code)")
|
||||
}
|
||||
val message = obj.optString("msg").ifBlank { obj.optString("title") }
|
||||
if (obj.optString("type") != "success") throw Exception(message.ifBlank { "Payment failed" })
|
||||
return Result(
|
||||
message = message,
|
||||
transactionId = obj.optString("tid").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
object MfaisaAccountClient {
|
||||
|
||||
/**
|
||||
* Build one BankAccount per pocket from a login result.
|
||||
* `loginTag` is "mfaisa_<msisdn>" (one per M-Faisa account on the device).
|
||||
*/
|
||||
fun buildAccounts(result: MfaisaLoginResult, loginTag: String): List<BankAccount> {
|
||||
val displayName = result.profile.name.ifBlank { "M-Faisa" }
|
||||
return result.pockets.map { p ->
|
||||
val balance = "%.2f".format(p.balance)
|
||||
BankAccount(
|
||||
bank = "MFAISA",
|
||||
profileName = displayName,
|
||||
profileType = if (p.pocketValueType == "PAYPAL_USD") "MFAISA_PAYPAL" else "MFAISA",
|
||||
accountNumber = p.pocketId,
|
||||
accountBriefName = p.nickname.ifBlank { p.displayName.ifBlank { "M-Faisa" } },
|
||||
currencyName = p.currency,
|
||||
accountTypeName = p.displayName.ifBlank { "Mobile Wallet" },
|
||||
availableBalance = balance,
|
||||
currentBalance = balance,
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (p.currency == "MVR") balance else "0.00",
|
||||
statusDesc = p.statusType.ifBlank { "ACTIVE" },
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = result.profile.subscriberId,
|
||||
internalId = result.profile.walletId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.util.Base64
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyFactory
|
||||
import java.security.PublicKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.OAEPParameterSpec
|
||||
import javax.crypto.spec.PSource
|
||||
|
||||
/**
|
||||
* Field-level encryption for Ooredoo M-Faisa request payloads.
|
||||
*
|
||||
* Both keys live (obfuscated) in libnative-lib.so and were extracted by hooking
|
||||
* the live app with Frida.
|
||||
*/
|
||||
object MfaisaCrypto {
|
||||
|
||||
// 1024-bit RSA key. Used for mdnId / mobileNumber / userName.
|
||||
// Plaintext is "960" + msisdn. Cipher is OAEP/SHA-256.
|
||||
private val MOBILE_N = BigInteger(
|
||||
"125043708524451715642963973698406708755269502293565460606118930542682275971580032704131362488150174351194407172452175275612284031366512484449720820404229217064541745811143629538982383390723079478499614160620616911679256603296752844216620113064874342531851472851319065258962732556596958868200227678294957694889"
|
||||
)
|
||||
private val MOBILE_E = BigInteger("65537")
|
||||
|
||||
// 2048-bit RSA key. Used for mPin. Plaintext is `pin + <6-char alphanumeric salt>`.
|
||||
// Cipher is OAEP/SHA-1. Output is hex.
|
||||
private val PIN_N = BigInteger(
|
||||
"30853988905151679601945771998041800603731623930944610745590884250489036547584511246061683594739124713335100655247634233703624305850983479131604065498722268916133039937128796419041248167624160300158401049118446352988895953596475734156239882174799821436218294725935232359347780127398770443981734096915599443841496235741614376221345134752344583283770986295156829944214841171989893291834036934949311011654192369326666754259268756426483563391867503815261490458479377640385950664660570354934951526319509191336410208609648686869010157285218492218371799827560010164293202383337546810220755107741865769246084291990864545504123"
|
||||
)
|
||||
private val PIN_E = BigInteger("65537")
|
||||
|
||||
private val mobileKey: PublicKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
|
||||
private val pinKey: PublicKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
|
||||
|
||||
private val random = SecureRandom()
|
||||
private const val SALT_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
/** Encrypts "960" + MSISDN. Output is non-deterministic (OAEP random padding). */
|
||||
fun encryptMobile(msisdn: String): String {
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, mobileKey, params)
|
||||
val ct = cipher.doFinal(("960" + msisdn).toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** Encrypts `pin + <6-char random alphanumeric salt>`. Output is hex (lowercase). */
|
||||
fun encryptPin(pin: String): String {
|
||||
val salt = buildString {
|
||||
repeat(6) { append(SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)]) }
|
||||
}
|
||||
val plaintext = (pin + salt).toByteArray(Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, pinKey, params)
|
||||
val ct = cipher.doFinal(plaintext)
|
||||
return ct.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
|
||||
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* Fetches the M-Faisa transaction summary for the active subscriber session.
|
||||
*
|
||||
* Endpoint: POST /transactionInquiry/fetchSummary
|
||||
*
|
||||
* Two extra anti-replay fields are required:
|
||||
* - rndValue : RSA-OAEP-SHA1 encryption of a fresh timestamp+salt with the mPin key
|
||||
* (i.e. the same routine as [MfaisaCrypto.encryptPin] applied to a timestamp)
|
||||
* - csValue : Adler32(formDataJson + timestampPlaintext), as a decimal string
|
||||
*/
|
||||
class MfaisaHistoryClient {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
/**
|
||||
* Fetches one page (`pageNo` is 1-based; recordSize defaults to 70 — the official app's value).
|
||||
* Returns the parsed transactions and a flag indicating whether the server returned a full
|
||||
* page (= more pages may be available).
|
||||
*/
|
||||
data class Page(val transactions: List<BankTransaction>, val hasMore: Boolean)
|
||||
|
||||
fun fetchHistory(
|
||||
session: MfaisaSession,
|
||||
accountNumber: String,
|
||||
accountDisplayName: String,
|
||||
pageNo: Int,
|
||||
recordSize: Int = 70
|
||||
): Page {
|
||||
if (session.loginExchangeKey.isBlank() || session.subscriberId.isBlank() || session.msisdn.isBlank()) {
|
||||
throw IllegalStateException("M-Faisa session is missing fields required for fetchSummary")
|
||||
}
|
||||
|
||||
val innerMdn = MfaisaCrypto.encryptMobile(session.msisdn)
|
||||
val outerMdn = MfaisaCrypto.encryptMobile(session.msisdn) // independent encryption
|
||||
|
||||
val formData = JSONObject()
|
||||
.put("actorRole", "RETAIL_SUBSCRIBER")
|
||||
.put("actorRoleId", session.subscriberId)
|
||||
.put("fromDate", "")
|
||||
.put("mdnId", innerMdn)
|
||||
.put("pageNo", pageNo.toString())
|
||||
.put("recordSize", recordSize.toString())
|
||||
.put("toDate", "")
|
||||
.put("transactionType", "")
|
||||
val formJson = formData.toString().matchGsonHtmlSafe()
|
||||
|
||||
// Anti-replay: nonce_str = (currentTimeMillis() + offset). Offset is small noise (0..5).
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
// rndValue uses the same key/cipher as the mPin encryption — see [MfaisaCrypto.encryptPin].
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply { update((formJson + nonceStr).toByteArray(Charsets.UTF_8)) }
|
||||
.value.toString()
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "SubscriberApp")
|
||||
.add("rndValue", rndValue)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("formData", formJson)
|
||||
.add("mdnId", outerMdn)
|
||||
.add("csValue", csValue)
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$baseUrl/transactionInquiry/fetchSummary").post(body).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty history response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
// The server returns its error envelope as a JSON array even on HTTP 200.
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
val first = errArr.optJSONObject(0)
|
||||
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
|
||||
val attrVal = errObj?.optString("attributeValue")
|
||||
val errCode = errObj?.optString("errorCode")
|
||||
if (attrVal == "SESSION_EXPIRED" || errCode == "SESSION_EXPIRED") {
|
||||
throw MfaisaSessionExpiredException()
|
||||
}
|
||||
val msg = errObj?.optString("errorMessage") ?: first?.optString("message")
|
||||
throw Exception(msg?.ifBlank { null } ?: "M-Faisa history failed")
|
||||
}
|
||||
|
||||
val obj = JSONObject(trimmed)
|
||||
val arr = obj.optJSONArray("transactionInquiryDTOList") ?: JSONArray()
|
||||
val out = mutableListOf<BankTransaction>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i) ?: continue
|
||||
out += parse(o, accountNumber, accountDisplayName)
|
||||
}
|
||||
// The server returns nothing useful for "total"; assume more pages exist when this page is full.
|
||||
return Page(out, hasMore = arr.length() >= recordSize)
|
||||
}
|
||||
|
||||
private fun parse(o: JSONObject, accountNumber: String, accountDisplayName: String): BankTransaction {
|
||||
val trnDate = o.optString("trnDate") // "yyyy-MM-dd HH:mm:ss" — already in target format
|
||||
val trnType = o.optString("trnType") // CASH_IN | PURCHASE | TRANSFER | …
|
||||
val status = o.optString("status") // SUCCESS | FAILED
|
||||
val amtObj = o.optJSONObject("transactionAmount") ?: JSONObject()
|
||||
val amount = amtObj.optDouble("amount", 0.0)
|
||||
val currency = amtObj.optString("currencyCode", "MVR")
|
||||
val refId = o.optString("referenceId").ifBlank { o.optString("requestId") }
|
||||
val narration = o.optString("narrationString").ifBlank { trnType.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } }
|
||||
|
||||
// Direction: CASH_IN / TRANSFER_IN etc. are credits, everything else is a debit
|
||||
val isCredit = trnType.endsWith("_IN") ||
|
||||
trnType == "CASH_IN" ||
|
||||
trnType == "RECEIVE_MONEY"
|
||||
val signedAmount = if (isCredit) amount else -amount
|
||||
|
||||
// Counterparty hint: parse the typeSummaryString for richer info if present
|
||||
val counterparty = extractCounterparty(o)
|
||||
|
||||
// Failed transactions still appear in the list — we still show them but tag in the description.
|
||||
val description = if (status == "FAILED") "$narration · Failed" else narration
|
||||
|
||||
return BankTransaction(
|
||||
id = refId,
|
||||
date = trnDate,
|
||||
description = description,
|
||||
amount = signedAmount,
|
||||
currency = currency,
|
||||
counterpartyName = counterparty,
|
||||
reference = refId.ifBlank { null },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "MFAISA"
|
||||
)
|
||||
}
|
||||
|
||||
/** Best-effort counterparty / merchant name extraction from the response's nested JSON. */
|
||||
private fun extractCounterparty(o: JSONObject): String? {
|
||||
// typeSummaryString is itself a JSON-encoded array string
|
||||
val ts = o.optString("typeSummaryString").trim()
|
||||
if (ts.startsWith("[")) {
|
||||
try {
|
||||
val arr = JSONArray(ts)
|
||||
for (i in 0 until arr.length()) {
|
||||
val item = arr.optJSONObject(i) ?: continue
|
||||
item.optString("Merchant Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
item.optString("Receiver Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
item.optString("Sender Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
}
|
||||
// sourceMDN like "Shiham-DT Pocket-9609198026" — the bit before the first dash is the user-facing name
|
||||
val source = o.optString("sourceMDN")
|
||||
if (source.isNotBlank() && source.contains("-")) return source.substringBefore("-")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the official app's Gson serialiser: replace `=` with the Unicode-escaped equivalent so
|
||||
* the M-Faisa server's strict parser accepts the payload (same trick as in [MfaisaLoginFlow]).
|
||||
*/
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MfaisaLoginFlow(context: Context) {
|
||||
|
||||
private val BASE_URL = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
|
||||
// Do NOT set User-Agent explicitly: Cloudflare in front of superapp.ooredoo.mv
|
||||
// fingerprints header order, and an explicit .header("User-Agent", ...) call
|
||||
// pushes it to the front of the request, returning 400. Letting OkHttp's
|
||||
// BridgeInterceptor add its default "okhttp/4.12.0" at the end matches the
|
||||
// official app's on-wire ordering and gets 200.
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Step 0: look up the subscriber by MSISDN and verify they have Full KYC.
|
||||
* Throws [MfaisaKycRequiredException] if kycStatus != "Full KYC".
|
||||
* Throws [MfaisaWalletNotReadyException] if the wallet isn't registered / activated / PIN-set.
|
||||
*/
|
||||
fun fetchSubscriber(msisdn: String): JSONObject {
|
||||
val body = JSONObject()
|
||||
.put("mdnId", MfaisaCrypto.encryptMobile(msisdn))
|
||||
.toString()
|
||||
.matchGsonHtmlSafe()
|
||||
.toRequestBody("application/json; charset=UTF-8".toMediaType())
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/fetchSubscriberByMDN")
|
||||
.post(body)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty subscriber response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
val obj = JSONObject(raw)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Could not look up this number" })
|
||||
}
|
||||
if (!obj.optBoolean("subscriberRegistered", false)) {
|
||||
throw MfaisaNotRegisteredException()
|
||||
}
|
||||
if (!obj.optBoolean("passwordCreated", false)) {
|
||||
throw MfaisaWalletNotReadyException("Set your M-Faisa mPIN in the Ooredoo SuperApp first, then try again.")
|
||||
}
|
||||
if (obj.optBoolean("activationPending", false)) {
|
||||
throw MfaisaWalletNotReadyException("Your M-Faisa wallet activation is still pending. Complete it in the Ooredoo SuperApp first.")
|
||||
}
|
||||
val kyc = obj.optString("kycStatus")
|
||||
if (kyc != "Full KYC") {
|
||||
throw MfaisaKycRequiredException(kyc)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: submit the PIN. Returns parsed login result on success.
|
||||
* Throws [MfaisaInvalidPinException] on a rejected PIN (with [MfaisaInvalidPinException.lastAttempt] = true
|
||||
* if the server's message says "one more wrong attempt will lock your account").
|
||||
*/
|
||||
fun doMobileLogin(msisdn: String, pin: String): MfaisaLoginResult {
|
||||
val mobileEnc = MfaisaCrypto.encryptMobile(msisdn)
|
||||
val userNameEnc = MfaisaCrypto.encryptMobile(msisdn) // independent encryption, same plaintext
|
||||
val pinEnc = MfaisaCrypto.encryptPin(pin)
|
||||
|
||||
val deviceId = androidId()
|
||||
val deviceGeo = JSONObject()
|
||||
.put("appType", "CustomerAndroid")
|
||||
.put("appversion", "1.0")
|
||||
.put("deviceId", deviceId)
|
||||
.put("deviceManufacturer", Build.MANUFACTURER)
|
||||
.put("imieNumber", deviceId)
|
||||
.put("ipaddress", "11.22.33.55")
|
||||
.put("latitude", "0.0")
|
||||
.put("longitude", "0.0")
|
||||
.put("simId", deviceId)
|
||||
|
||||
val formData = JSONObject()
|
||||
.put("deviceGeoInfo", deviceGeo)
|
||||
.put("mPin", pinEnc)
|
||||
.put("mobileNumber", mobileEnc)
|
||||
.put("role", "RETAIL_SUBSCRIBER")
|
||||
.put("tenantCode", "ooredoo")
|
||||
.put("userName", userNameEnc)
|
||||
.toString()
|
||||
.matchGsonHtmlSafe()
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("channel", "C03")
|
||||
.add("formData", formData)
|
||||
.add("formDataCs", "null")
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/doMobileLogin")
|
||||
.post(body)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty login response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
// Wrong-PIN response is a JSON array; success is an object.
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val arr = JSONArray(trimmed)
|
||||
val first = arr.optJSONObject(0)
|
||||
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
|
||||
val msg = errObj?.optString("errorMessage")
|
||||
?: first?.optString("message") ?: "Login failed"
|
||||
val lastAttempt = msg.contains("one more", ignoreCase = true) ||
|
||||
msg.contains("will lock", ignoreCase = true)
|
||||
throw MfaisaInvalidPinException(msg, lastAttempt)
|
||||
}
|
||||
|
||||
val obj = try { JSONObject(trimmed) } catch (e: JSONException) {
|
||||
throw Exception("Unexpected login response")
|
||||
}
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw MfaisaInvalidPinException(obj.optString("message").ifBlank { "Login failed" }, false)
|
||||
}
|
||||
// Defensive: server also returns kycStatus on success.
|
||||
val kyc = obj.optString("kycStatus")
|
||||
if (kyc.isNotBlank() && kyc != "Full KYC") {
|
||||
throw MfaisaKycRequiredException(kyc)
|
||||
}
|
||||
|
||||
val session = MfaisaSession(
|
||||
loginExchangeKey = obj.optString("loginExchangeKey"),
|
||||
sessionTimeoutSec = obj.optString("mobileLoginSessionTimeout").toIntOrNull() ?: 240,
|
||||
msisdn = msisdn,
|
||||
subscriberId = obj.optString("suscriberId")
|
||||
)
|
||||
|
||||
// pocketDetails[0] holds this user's identity + pockets.
|
||||
val pd = obj.optJSONArray("pocketDetails")?.optJSONObject(0) ?: JSONObject()
|
||||
val profile = MfaisaUserProfile(
|
||||
name = pd.optString("name").ifBlank { "M-Faisa" },
|
||||
email = pd.optString("eMailId"),
|
||||
mdnId = pd.optString("mdnId").ifBlank { msisdn },
|
||||
roleId = pd.optString("roleId"),
|
||||
walletId = pd.optString("walletId"),
|
||||
subscriberId = obj.optString("suscriberId"),
|
||||
offerId = pd.optString("offerId")
|
||||
)
|
||||
val pockets = mutableListOf<MfaisaPocket>()
|
||||
val pktArr = pd.optJSONArray("pocketSummaryDetailsArrayDTO") ?: JSONArray()
|
||||
for (i in 0 until pktArr.length()) {
|
||||
val p = pktArr.getJSONObject(i)
|
||||
val bal = p.optJSONObject("balanceAmount") ?: JSONObject()
|
||||
pockets += MfaisaPocket(
|
||||
pocketId = p.optString("pocketId"),
|
||||
pocketType = p.optString("pocketType"),
|
||||
pocketValueType = p.optString("pocketValueType"),
|
||||
nickname = p.optString("nickName"),
|
||||
currency = bal.optString("currencyCode", "MVR"),
|
||||
balance = bal.optDouble("amount", 0.0),
|
||||
isDefault = p.optBoolean("isDefaultPocket", false),
|
||||
isSecondary = p.optBoolean("isSecondaryPocket", false),
|
||||
statusType = p.optString("statusType"),
|
||||
displayName = p.optString("displayName")
|
||||
)
|
||||
}
|
||||
|
||||
return MfaisaLoginResult(session, profile, pockets)
|
||||
}
|
||||
|
||||
private fun androidId(): String {
|
||||
return Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
?: "0000000000000000"
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the body byte-identical to what the official app's Gson serializer emits:
|
||||
* 1. `\/` (org.json's default escape for `/`) → `/`
|
||||
* 2. `=` → `=` (Gson `htmlSafe` mode)
|
||||
* Both are technically valid JSON either way, but the M-Faisa server's parser appears strict.
|
||||
*/
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
data class MfaisaSession(
|
||||
val loginExchangeKey: String,
|
||||
val sessionTimeoutSec: Int,
|
||||
val msisdn: String = "",
|
||||
val subscriberId: String = ""
|
||||
)
|
||||
|
||||
/** Subset of the doMobileLogin success response we keep around. */
|
||||
data class MfaisaUserProfile(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val mdnId: String,
|
||||
val roleId: String,
|
||||
val walletId: String,
|
||||
val subscriberId: String,
|
||||
val offerId: String
|
||||
)
|
||||
|
||||
/** Pocket = M-Faisa balance bucket (E-Money MVR, IMT MVR, PayPal USD). */
|
||||
data class MfaisaPocket(
|
||||
val pocketId: String,
|
||||
val pocketType: String, // INTERNAL, ...
|
||||
val pocketValueType: String, // EMONEY, PAYPAL_USD, ...
|
||||
val nickname: String,
|
||||
val currency: String, // MVR, USD
|
||||
val balance: Double,
|
||||
val isDefault: Boolean,
|
||||
val isSecondary: Boolean,
|
||||
val statusType: String,
|
||||
val displayName: String
|
||||
)
|
||||
|
||||
data class MfaisaLoginResult(
|
||||
val session: MfaisaSession,
|
||||
val profile: MfaisaUserProfile,
|
||||
val pockets: List<MfaisaPocket>
|
||||
)
|
||||
|
||||
/** Thrown when the wallet is not "Full KYC" — login must abort. */
|
||||
class MfaisaKycRequiredException(val kycStatus: String) :
|
||||
Exception("M-Faisa wallet is not fully verified (kycStatus=$kycStatus)")
|
||||
|
||||
/** Thrown when this MSISDN has no M-Faisa wallet at all — user must sign up in the Ooredoo SuperApp. */
|
||||
class MfaisaNotRegisteredException : Exception("This number does not have an M-Faisa wallet")
|
||||
|
||||
/** Thrown when fetchSubscriberByMDN says the wallet exists but is not yet usable (no PIN, activation pending, …). */
|
||||
class MfaisaWalletNotReadyException(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* Thrown for an invalid PIN. The PIN field should be re-enabled.
|
||||
* [lastAttempt] is true when the server's message warns the user one more wrong attempt will lock their account.
|
||||
*/
|
||||
class MfaisaInvalidPinException(message: String, val lastAttempt: Boolean = false) : Exception(message)
|
||||
|
||||
/**
|
||||
* Thrown when a session-scoped M-Faisa endpoint returns the
|
||||
* `[{ ..., "attributeValue": "SESSION_EXPIRED", ... }]` envelope (still as HTTP 200).
|
||||
* Callers should re-run the login (`fetchSubscriber` + `doMobileLogin`) using the saved
|
||||
* credentials and retry the request once.
|
||||
*/
|
||||
class MfaisaSessionExpiredException : Exception("M-Faisa session expired")
|
||||
|
||||
/** Thrown by [MfaisaTransferClient.searchRecipient] when no M-Faisa wallet exists for the queried MSISDN. */
|
||||
class MfaisaRecipientNotFoundException : Exception("No M-Faisa wallet found for this number")
|
||||
|
||||
/** Thrown by [MfaisaTransferClient.confirmTransfer] when the OTP is rejected. */
|
||||
class MfaisaInvalidOtpException(message: String) : Exception(message)
|
||||
@@ -0,0 +1,199 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* M-Faisa merchant QR payment ("smart pay") flow:
|
||||
* 1. [fetchQrDetails] POST /QRCodeUtility/fetchQRCodeById — resolve qrCodeId to merchant
|
||||
* 2. [initiatePurchase] POST /initiateNewBuy — start the purchase, returns referenceId.
|
||||
* Server returns 2FARequired=NONE for wallet QR pay,
|
||||
* so no OTP is required.
|
||||
* 3. [confirmPurchase] POST /confirmNewBuy — settles the purchase. `transactionAuthDetails`
|
||||
* is sent as the literal string "null".
|
||||
*
|
||||
* Anti-replay scheme is the same as [MfaisaTransferClient]: rndValue = encryptPin(timestampStr),
|
||||
* csValue = Adler32(formDataJson + timestampStr). The server responds with `[{...}]` envelopes
|
||||
* for both success and error — callers must check the `success` flag.
|
||||
*/
|
||||
class MfaisaQrPayClient {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val random = SecureRandom()
|
||||
|
||||
/** Resolved merchant for a scanned M-Faisa QR. */
|
||||
data class QrMerchant(
|
||||
val qrCodeId: String,
|
||||
val merchantId: String, // customerId from the lookup response
|
||||
val merchantName: String, // commercialName
|
||||
val merchantMsisdn: String, // mobileNumber — already includes "960" prefix
|
||||
val currencyCode: String, // e.g. "MVR"
|
||||
/** Pre-set amount for a dynamic QR; null for a static QR (user enters amount). */
|
||||
val txnAmount: String?,
|
||||
val status: String // "Active" for usable QRs
|
||||
)
|
||||
|
||||
// ─── Step 1: resolve qrCodeId → merchant details ─────────────────────────
|
||||
|
||||
fun fetchQrDetails(session: MfaisaSession, qrCodeId: String): QrMerchant {
|
||||
val formData = JSONObject()
|
||||
.put("qrCodeId", qrCodeId)
|
||||
.put("tenantCode", "ooredoo")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
// Note: fetchQRCodeById uses role=R01 (not RETAIL_SUBSCRIBER like the other two endpoints).
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "R01")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val first = postAndUnwrap("$baseUrl/QRCodeUtility/fetchQRCodeById", body, "QR lookup failed")
|
||||
val response = first.optJSONArray("response")?.optJSONObject(0)
|
||||
?: throw Exception("QR code not found")
|
||||
if (!response.optString("status").equals("Active", ignoreCase = true)) {
|
||||
throw Exception("QR code is not active")
|
||||
}
|
||||
|
||||
// The lookup response stores absent values as the literal JSON null (decoded by org.json as
|
||||
// `JSONObject.NULL`) — optString surfaces that as the string "null". Guard against both.
|
||||
fun strOrNull(name: String): String? = response.opt(name)
|
||||
?.takeIf { it != JSONObject.NULL }
|
||||
?.toString()
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
return QrMerchant(
|
||||
qrCodeId = strOrNull("qrCodeId") ?: qrCodeId,
|
||||
merchantId = strOrNull("customerId") ?: throw Exception("Merchant id missing"),
|
||||
merchantName = strOrNull("commercialName") ?: throw Exception("Merchant name missing"),
|
||||
merchantMsisdn = strOrNull("mobileNumber") ?: throw Exception("Merchant number missing"),
|
||||
currencyCode = strOrNull("currencyCode") ?: "MVR",
|
||||
txnAmount = strOrNull("txnAmount"),
|
||||
status = response.optString("status")
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Step 2: initiate the purchase ───────────────────────────────────────
|
||||
|
||||
/** Returns the `referenceId` to be passed to [confirmPurchase]. */
|
||||
fun initiatePurchase(
|
||||
session: MfaisaSession,
|
||||
sourcePocketId: String,
|
||||
sourceMsisdn: String, // user's "960..." MSISDN
|
||||
merchant: QrMerchant,
|
||||
amount: String,
|
||||
description: String = ""
|
||||
): String {
|
||||
val formData = JSONObject()
|
||||
.put("channel", "SubscriberApp")
|
||||
.put("commodityType", "WALLET")
|
||||
.put("description", description)
|
||||
.put("merchantId", merchant.merchantId)
|
||||
.put("mobileNumber", merchant.merchantMsisdn)
|
||||
.put("sourceDetails", JSONObject()
|
||||
.put("MDNId", sourceMsisdn)
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER")
|
||||
.put("pocketId", sourcePocketId))
|
||||
.put("transactionAmount", amount)
|
||||
.put("transactionCurrency", merchant.currencyCode)
|
||||
.put("transactionType", "PURCHASE")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val first = postAndUnwrap("$baseUrl/initiateNewBuy", body, "Payment initiation failed")
|
||||
// We've only seen 2FARequired=NONE for wallet QR pay. If the server ever asks for OTP we
|
||||
// surface a clear error instead of silently completing a no-op confirm.
|
||||
val twoFa = first.optString("2FARequired").ifBlank { "NONE" }
|
||||
if (!twoFa.equals("NONE", ignoreCase = true)) {
|
||||
throw Exception("This QR requires 2FA ($twoFa) which is not yet supported")
|
||||
}
|
||||
val responseObj = first.optJSONArray("response")?.optJSONObject(0)?.optJSONObject("responseObject")
|
||||
?: throw Exception("Missing responseObject")
|
||||
val refId = responseObj.optString("referenceId")
|
||||
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
|
||||
return refId
|
||||
}
|
||||
|
||||
// ─── Step 3: confirm (no OTP) ────────────────────────────────────────────
|
||||
|
||||
fun confirmPurchase(session: MfaisaSession, referenceId: String) {
|
||||
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
// Literal string "null" — matches the captured request from the official app.
|
||||
.add("transactionAuthDetails", "null")
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
postAndUnwrap("$baseUrl/confirmNewBuy", body, "Payment confirmation failed")
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** POSTs [body] to [url], unwraps the `[{...}]` envelope, throws on non-success / session expiry. */
|
||||
private fun postAndUnwrap(url: String, body: okhttp3.RequestBody, fallbackError: String): JSONObject {
|
||||
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
val arr = JSONArray(raw.trimStart())
|
||||
val first = arr.optJSONObject(0) ?: throw Exception(fallbackError)
|
||||
handleSessionExpiry(first)
|
||||
if (!first.optBoolean("success", false)) {
|
||||
val errObj = first.optJSONArray("error")?.optJSONObject(0)
|
||||
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null }
|
||||
?: first.optString("message").ifBlank { fallbackError })
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
private fun handleSessionExpiry(envelope: JSONObject?) {
|
||||
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
|
||||
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
|
||||
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
|
||||
}
|
||||
|
||||
private fun makeAntiReplay(formJson: String): Pair<String, String> {
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply {
|
||||
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString()
|
||||
return rndValue to csValue
|
||||
}
|
||||
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* Three-step M-Faisa transfer flow:
|
||||
* 1. [searchRecipient] POST /Pocket/basicBeneDetails — look up the recipient
|
||||
* 2. [initiateTransfer] POST /initiateFTRequest — kicks off the transfer; server SMSes an OTP
|
||||
* 3. [confirmTransfer] POST /confirmFTRequest — submit the OTP to actually move the money
|
||||
*
|
||||
* Every request uses the same anti-replay scheme as the history endpoint (see [MfaisaHistoryClient]):
|
||||
* `rndValue` = `encryptPin(timestampStr)` and `csValue` = `Adler32(formDataJson + timestampStr)`.
|
||||
*
|
||||
* On a session timeout the server returns `[{... "attributeValue":"SESSION_EXPIRED" ...}]` with HTTP 200;
|
||||
* each method throws [MfaisaSessionExpiredException] in that case so the caller can re-login and retry.
|
||||
*/
|
||||
class MfaisaTransferClient(private val deviceId: String) {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val random = SecureRandom()
|
||||
|
||||
// ─── Step 1: recipient lookup ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of [searchRecipient]. The MVR pocket (`isMvr`) is the only target for outgoing transfers
|
||||
* in Thijooree — PayPal pockets are not supported as recipients.
|
||||
*/
|
||||
data class Recipient(
|
||||
val name: String,
|
||||
val msisdn: String, // already includes the "960" prefix, as returned by the server
|
||||
val mvrPocketId: String?,
|
||||
val paypalPocketId: String?,
|
||||
val walletId: String,
|
||||
val actorId: String
|
||||
) {
|
||||
val isMvr: Boolean get() = mvrPocketId != null
|
||||
}
|
||||
|
||||
/** @throws MfaisaRecipientNotFoundException if no M-Faisa wallet exists for [recipientMsisdn]. */
|
||||
fun searchRecipient(session: MfaisaSession, recipientMsisdn: String): Recipient {
|
||||
require(session.msisdn.isNotBlank() && session.subscriberId.isNotBlank()) {
|
||||
"session is missing fields required for basicBeneDetails"
|
||||
}
|
||||
val formData = JSONObject()
|
||||
.put("beneficaryDetails", JSONObject()
|
||||
.put("MDNId", MfaisaCrypto.encryptMobile(recipientMsisdn))
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER"))
|
||||
.put("initiatorDetailsDTO", JSONObject()
|
||||
.put("initiatingMDN", MfaisaCrypto.encryptMobile(session.msisdn))
|
||||
.put("initiatingRoleId", session.subscriberId)
|
||||
.put("initiatorRole", "RETAIL_SUBSCRIBER"))
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "SubscriberApp")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/Pocket/basicBeneDetails", body)
|
||||
// Response shape: `[{ success, response: [[pocket1, pocket2, ...]] }]`
|
||||
val arr = JSONArray(raw.trimStart())
|
||||
val first = arr.optJSONObject(0) ?: throw Exception("Unexpected response")
|
||||
if (!first.optBoolean("success", false)) {
|
||||
handleSessionExpiry(first)
|
||||
val msg = first.optString("message")
|
||||
if (msg.contains("not found", ignoreCase = true)) throw MfaisaRecipientNotFoundException()
|
||||
throw Exception(msg.ifBlank { "Recipient lookup failed" })
|
||||
}
|
||||
val outer = first.optJSONArray("response") ?: throw Exception("Empty recipient list")
|
||||
val pockets = outer.optJSONArray(0) ?: throw MfaisaRecipientNotFoundException()
|
||||
if (pockets.length() == 0) throw MfaisaRecipientNotFoundException()
|
||||
|
||||
var name = ""
|
||||
var msisdn = ""
|
||||
var mvr: String? = null
|
||||
var paypal: String? = null
|
||||
var wallet = ""
|
||||
var actor = ""
|
||||
for (i in 0 until pockets.length()) {
|
||||
val p = pockets.getJSONObject(i)
|
||||
if (name.isBlank()) name = p.optString("name")
|
||||
if (msisdn.isBlank()) msisdn = p.optString("MDNId")
|
||||
if (wallet.isBlank()) wallet = p.optString("walletId")
|
||||
if (actor.isBlank()) actor = p.optString("actorId")
|
||||
when (p.optString("pocketValueType")) {
|
||||
"EMONEY" -> mvr = p.optString("pocketId")
|
||||
"PAYPAL_USD" -> paypal = p.optString("pocketId")
|
||||
}
|
||||
}
|
||||
return Recipient(name, msisdn, mvr, paypal, wallet, actor)
|
||||
}
|
||||
|
||||
// ─── Step 2: initiate (server sends OTP) ─────────────────────────────────
|
||||
|
||||
/** Returns the `referenceId` to be passed to [confirmTransfer]. */
|
||||
fun initiateTransfer(
|
||||
session: MfaisaSession,
|
||||
sourcePocketId: String,
|
||||
recipient: Recipient,
|
||||
amount: String,
|
||||
description: String
|
||||
): String {
|
||||
require(recipient.isMvr) { "M-Faisa transfers can only target the recipient's MVR pocket" }
|
||||
|
||||
// Inner formData JSON. The server expects PLAINTEXT mobile numbers here (already
|
||||
// prefixed with "960" — recipient.msisdn already includes that), unlike step 1 which
|
||||
// encrypts them.
|
||||
val formData = JSONObject()
|
||||
.put("MDNId", recipient.msisdn)
|
||||
.put("beneDetails", JSONObject()
|
||||
.put("miscDetails", description)
|
||||
.put("transferMode", "MOBILE"))
|
||||
.put("channel", "SubscriberApp")
|
||||
.put("commodityType", "WALLET")
|
||||
.put("description", description)
|
||||
.put("inputDetailsDTO", JSONObject()
|
||||
.put("deviceId", deviceId)
|
||||
.put("simId", deviceId))
|
||||
.put("mfs-transactionType", "send-money-to-mobile")
|
||||
.put("pocketId", "")
|
||||
.put("sourceDetails", JSONObject()
|
||||
.put("MDNId", "960${session.msisdn}")
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER")
|
||||
.put("pocketId", sourcePocketId))
|
||||
.put("transactionAmount", amount)
|
||||
.put("transactionCurrency", "MVR")
|
||||
.put("transferMode", "MOBILE")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
// The "identifier" top-level field is the recipient MDN re-encrypted (matches step 1's
|
||||
// `beneficaryDetails.MDNId` plaintext; independent OAEP randomness gives a different ciphertext).
|
||||
val identifier = MfaisaCrypto.encryptMobile(recipient.msisdn.removePrefix("960"))
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("identifier", identifier)
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("transferMode", "MOBILE")
|
||||
.add("channel", "C03") // NB: top-level "C03", inner formData.channel is "SubscriberApp"
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("tPin", "")
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/initiateFTRequest", body)
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
handleSessionExpiry(errArr.optJSONObject(0))
|
||||
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
|
||||
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null } ?: "Transfer initiation failed")
|
||||
}
|
||||
val obj = JSONObject(trimmed)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Transfer initiation failed" })
|
||||
}
|
||||
val responseArr = obj.optJSONArray("response") ?: throw Exception("Missing response array")
|
||||
val responseObj = responseArr.optJSONObject(0)?.optJSONObject("responseObject")
|
||||
?: throw Exception("Missing responseObject")
|
||||
val refId = responseObj.optString("referenceId")
|
||||
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
|
||||
return refId
|
||||
}
|
||||
|
||||
// ─── Step 3: confirm with OTP ────────────────────────────────────────────
|
||||
|
||||
/** Submits [otpCode] for [referenceId]. Throws on invalid OTP / server failure. */
|
||||
fun confirmTransfer(session: MfaisaSession, referenceId: String, otpCode: String) {
|
||||
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
|
||||
val transactionAuthDetails = JSONObject()
|
||||
.put("authenticationType", "OTP")
|
||||
.put("authenticationValue", MfaisaCrypto.encryptPin(otpCode)) // same cipher as PIN
|
||||
.put("otpTransactionType", "TRANSACTION")
|
||||
.put("referenceId", referenceId)
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("transactionAuthDetails", transactionAuthDetails)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/confirmFTRequest", body)
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
handleSessionExpiry(errArr.optJSONObject(0))
|
||||
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
|
||||
val attr = errObj?.optString("attributeName")
|
||||
val msg = errObj?.optString("errorMessage")?.ifBlank { null }
|
||||
?: errArr.optJSONObject(0)?.optString("message")
|
||||
if (attr.equals("OTP", ignoreCase = true) || (msg ?: "").contains("OTP", ignoreCase = true)) {
|
||||
throw MfaisaInvalidOtpException(msg ?: "Invalid OTP")
|
||||
}
|
||||
throw Exception(msg ?: "Transfer confirmation failed")
|
||||
}
|
||||
val obj = JSONObject(trimmed)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Transfer confirmation failed" })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun execute(url: String, body: okhttp3.RequestBody): String {
|
||||
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun handleSessionExpiry(envelope: JSONObject?) {
|
||||
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
|
||||
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
|
||||
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
|
||||
}
|
||||
|
||||
private fun makeAntiReplay(formJson: String): Pair<String, String> {
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply {
|
||||
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString()
|
||||
return rndValue to csValue
|
||||
}
|
||||
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
|
||||
companion object {
|
||||
/** Convenience factory that pulls the device identifier the way [MfaisaLoginFlow] does. */
|
||||
fun forContext(context: android.content.Context): MfaisaTransferClient {
|
||||
val id = Settings.Secure.getString(context.applicationContext.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
?: "0000000000000000"
|
||||
// suppress "unused" — kept for symmetry with MfaisaLoginFlow if we later read Build.MANUFACTURER.
|
||||
@Suppress("UNUSED_VARIABLE") val mfg = Build.MANUFACTURER
|
||||
return MfaisaTransferClient(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ class MibHistoryClient {
|
||||
id = item.optString("trxNumber"),
|
||||
date = item.optString("trxDate"),
|
||||
description = item.optString("descr1").trim(),
|
||||
amount = item.optString("baseAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
currency = item.optString("curCodeDesc"),
|
||||
counterpartyName = item.optString("benefName").takeIf {
|
||||
it.isNotBlank() && it != "null"
|
||||
|
||||
@@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
.build()
|
||||
|
||||
/** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */
|
||||
fun updateOtpSeed(otpSeed: String) {
|
||||
if (storedOtpSeed != null) storedOtpSeed = otpSeed
|
||||
}
|
||||
|
||||
// ─── Public entry point ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,7 +42,8 @@ data class MibTransferResult(
|
||||
data class MibIpsAccountInfo(
|
||||
val accountName: String,
|
||||
val accountNumber: String,
|
||||
val bankId: String
|
||||
val bankId: String,
|
||||
val currency: String = "" // "MVR", "USD", or "" if unknown
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -130,7 +130,10 @@ class MibTransferClient {
|
||||
MibIpsAccountInfo(
|
||||
accountName = json.optString("accountName").trim(),
|
||||
accountNumber = accountNumber,
|
||||
bankId = json.optString("bankBic")
|
||||
bankId = json.optString("bankBic"),
|
||||
// MIB IPS only returns success for MVR cross-bank accounts;
|
||||
// USD cross-bank accounts fail this lookup entirely.
|
||||
currency = "MVR"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,10 +159,18 @@ class MibTransferClient {
|
||||
// accountName may be at root or inside a "data" object
|
||||
val name = json.optString("accountName").takeIf { it.isNotBlank() }
|
||||
?: json.optJSONObject("data")?.optString("accountName") ?: ""
|
||||
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
|
||||
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
|
||||
val currency = when (currencyCode) {
|
||||
"840" -> "USD"
|
||||
"462" -> "MVR"
|
||||
else -> ""
|
||||
}
|
||||
MibIpsAccountInfo(
|
||||
accountName = name.trim(),
|
||||
accountNumber = accountNumber,
|
||||
bankId = "MADVMVMV" // MIB
|
||||
bankId = "MADVMVMV", // MIB
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package sh.sar.basedbank.api.ooredoo
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
|
||||
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
|
||||
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
|
||||
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
|
||||
*
|
||||
* Every call blocks, so run it on an IO thread.
|
||||
*/
|
||||
class OoredooPaymentClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
|
||||
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
|
||||
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
|
||||
* the order is refused.
|
||||
*/
|
||||
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
|
||||
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
|
||||
|
||||
/**
|
||||
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
|
||||
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
|
||||
* transaction id. Throws with Ooredoo's wording when the order is refused.
|
||||
*/
|
||||
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
|
||||
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
|
||||
|
||||
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
|
||||
private fun createOrder(
|
||||
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
|
||||
): String {
|
||||
val msisdn = "960$number"
|
||||
val body = JSONObject()
|
||||
.put("msisdn", msisdn)
|
||||
.put("purchaseAmount", money(charged))
|
||||
.put("amountWithoutGst", amountWithoutGst)
|
||||
.put("receiverMsisdn", msisdn)
|
||||
.put("transType", transType)
|
||||
.put("serviceType", serviceType)
|
||||
.put("serviceTypeDisplayName", "Mobile")
|
||||
val text = client.newCall(
|
||||
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
|
||||
.post(body.toString().toRequestBody(JSON))
|
||||
.header("User-Agent", UA)
|
||||
.header("Accept", "application/json")
|
||||
.header("Origin", BASE)
|
||||
.build()
|
||||
).execute().use { r ->
|
||||
if (r.code in 500..599) throw BankServerException("Ooredoo")
|
||||
r.body?.string().orEmpty()
|
||||
}
|
||||
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||
throw Exception("Unexpected response from Ooredoo")
|
||||
}
|
||||
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
|
||||
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
|
||||
}
|
||||
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
|
||||
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
|
||||
?: throw Exception("BML didn't create the transaction")
|
||||
}
|
||||
|
||||
private fun money(amount: BigDecimal) =
|
||||
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||
|
||||
companion object {
|
||||
private const val BASE = "https://www.ooredoo.mv"
|
||||
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
private val JSON = "application/json".toMediaType()
|
||||
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package sh.sar.basedbank.nfc
|
||||
|
||||
import android.nfc.Tag
|
||||
import android.nfc.tech.IsoDep
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
/**
|
||||
* Minimal contactless EMV reader: selects the payment app, runs GPO and reads the
|
||||
* AFL records until it finds the PAN (tag 5A / Track 2 tag 57) and expiry (5F24 / Track 2).
|
||||
*/
|
||||
object EmvCardReader {
|
||||
|
||||
/** [expiry] is "MM/YY". */
|
||||
data class CardData(val pan: String, val expiry: String?)
|
||||
|
||||
private class Collected {
|
||||
var pan: String? = null
|
||||
var expiry: String? = null
|
||||
val complete get() = pan != null && expiry != null
|
||||
fun result() = pan?.let { CardData(it, expiry) }
|
||||
}
|
||||
|
||||
/** Returns the card data, or null if the PAN couldn't be read. Blocking — call off the main thread. */
|
||||
fun read(tag: Tag): CardData? {
|
||||
val iso = IsoDep.get(tag) ?: return null
|
||||
val c = Collected()
|
||||
iso.use {
|
||||
it.connect()
|
||||
it.timeout = 5000
|
||||
|
||||
val aids = selectPpse(it).ifEmpty { KNOWN_AIDS }
|
||||
for (aid in aids) {
|
||||
val fci = transceive(it, selectApdu(aid)) ?: continue
|
||||
val pdol = findTag(fci, 0x9F38)
|
||||
val gpo = transceive(it, gpoApdu(pdol)) ?: continue
|
||||
collect(gpo, c)
|
||||
if (c.complete) return c.result()
|
||||
|
||||
// Format 1 (tag 80): AIP (2 bytes) + AFL. Format 2 (tag 77): AFL in tag 94.
|
||||
val afl = findTag(gpo, 0x94)
|
||||
?: findTag(gpo, 0x80)?.let { b -> if (b.size > 2) b.copyOfRange(2, b.size) else null }
|
||||
?: continue
|
||||
for (i in 0 until afl.size / 4) {
|
||||
val sfi = (afl[i * 4].toInt() and 0xFF) shr 3
|
||||
val first = afl[i * 4 + 1].toInt() and 0xFF
|
||||
val last = afl[i * 4 + 2].toInt() and 0xFF
|
||||
for (rec in first..last) {
|
||||
val data = transceive(it, readRecordApdu(sfi, rec)) ?: continue
|
||||
collect(data, c)
|
||||
if (c.complete) return c.result()
|
||||
}
|
||||
}
|
||||
if (c.pan != null) return c.result()
|
||||
}
|
||||
}
|
||||
return c.result()
|
||||
}
|
||||
|
||||
private val KNOWN_AIDS = listOf(
|
||||
"A0000000031010", // Visa
|
||||
"A0000000041010", // Mastercard
|
||||
"A0000000043060", // Maestro
|
||||
"A000000025010801", // Amex
|
||||
"A0000003330101", // UnionPay
|
||||
).map { hex(it) }
|
||||
|
||||
private fun selectPpse(iso: IsoDep): List<ByteArray> {
|
||||
val resp = transceive(iso, selectApdu("2PAY.SYS.DDF01".toByteArray())) ?: return emptyList()
|
||||
return findAllTags(resp, 0x4F)
|
||||
}
|
||||
|
||||
private fun collect(data: ByteArray, c: Collected) {
|
||||
findTag(data, 0x5A)?.let { c.pan = c.pan ?: toHex(it).trimEnd('F') }
|
||||
findTag(data, 0x57)?.let { raw ->
|
||||
val t2 = toHex(raw)
|
||||
c.pan = c.pan ?: t2.substringBefore('D')
|
||||
// Track 2: PAN 'D' YYMM service-code ...
|
||||
val yymm = t2.substringAfter('D', "").take(4)
|
||||
if (c.expiry == null && yymm.length == 4) c.expiry = "${yymm.substring(2, 4)}/${yymm.substring(0, 2)}"
|
||||
}
|
||||
findTag(data, 0x5F24)?.let { raw ->
|
||||
val yymmdd = toHex(raw)
|
||||
if (yymmdd.length >= 4) c.expiry = "${yymmdd.substring(2, 4)}/${yymmdd.substring(0, 2)}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectApdu(aid: ByteArray): ByteArray =
|
||||
byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) + aid + byteArrayOf(0x00)
|
||||
|
||||
private fun readRecordApdu(sfi: Int, rec: Int): ByteArray =
|
||||
byteArrayOf(0x00, 0xB2.toByte(), rec.toByte(), ((sfi shl 3) or 0x04).toByte(), 0x00)
|
||||
|
||||
/** Builds GPO with the PDOL filled in: sensible TTQ/country/currency/date, zeros otherwise. */
|
||||
private fun gpoApdu(pdol: ByteArray?): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
if (pdol != null) {
|
||||
var i = 0
|
||||
while (i < pdol.size) {
|
||||
var tag = pdol[i].toInt() and 0xFF
|
||||
i++
|
||||
if (tag and 0x1F == 0x1F) {
|
||||
do {
|
||||
tag = (tag shl 8) or (pdol[i].toInt() and 0xFF)
|
||||
} while (pdol[i++].toInt() and 0x80 != 0 && i < pdol.size)
|
||||
}
|
||||
if (i >= pdol.size) break
|
||||
val len = pdol[i++].toInt() and 0xFF
|
||||
val value = when (tag) {
|
||||
0x9F66 -> hex("B620C000") // TTQ: contactless qVSDC, online capable
|
||||
0x9F1A, 0x5F2A -> hex("0462") // Maldives / MVR
|
||||
0x9A -> hex("260101")
|
||||
0x9C -> hex("00")
|
||||
0x9F37 -> hex("12345678")
|
||||
else -> ByteArray(len)
|
||||
}
|
||||
out.write(value.copyOf(len))
|
||||
}
|
||||
}
|
||||
val pdolData = out.toByteArray()
|
||||
val body = byteArrayOf(0x83.toByte(), pdolData.size.toByte()) + pdolData
|
||||
return byteArrayOf(0x80.toByte(), 0xA8.toByte(), 0x00, 0x00, body.size.toByte()) + body + byteArrayOf(0x00)
|
||||
}
|
||||
|
||||
/** Sends an APDU, returning the response data on 9000 (following 61xx / 6Cxx), else null. */
|
||||
private fun transceive(iso: IsoDep, apdu: ByteArray): ByteArray? {
|
||||
var resp = iso.transceive(apdu)
|
||||
if (resp.size < 2) return null
|
||||
var sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||
if (sw1 == 0x6C) {
|
||||
val retry = apdu.copyOf()
|
||||
retry[retry.size - 1] = resp[resp.size - 1]
|
||||
resp = iso.transceive(retry)
|
||||
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||
}
|
||||
if (sw1 == 0x61) {
|
||||
resp = iso.transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, resp[resp.size - 1]))
|
||||
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||
}
|
||||
val sw2 = resp[resp.size - 1].toInt() and 0xFF
|
||||
return if (sw1 == 0x90 && sw2 == 0x00) resp.copyOf(resp.size - 2) else null
|
||||
}
|
||||
|
||||
// ── BER-TLV ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun findTag(data: ByteArray, target: Int): ByteArray? = findAllTags(data, target).firstOrNull()
|
||||
|
||||
private fun findAllTags(data: ByteArray, target: Int): List<ByteArray> {
|
||||
val found = mutableListOf<ByteArray>()
|
||||
walk(data, 0, data.size, target, found)
|
||||
return found
|
||||
}
|
||||
|
||||
private fun walk(data: ByteArray, start: Int, end: Int, target: Int, found: MutableList<ByteArray>) {
|
||||
var i = start
|
||||
while (i < end) {
|
||||
val b0 = data[i].toInt() and 0xFF
|
||||
if (b0 == 0x00 || b0 == 0xFF) { i++; continue } // padding
|
||||
val constructed = b0 and 0x20 != 0
|
||||
var tag = b0
|
||||
i++
|
||||
if (b0 and 0x1F == 0x1F) {
|
||||
while (i < end) {
|
||||
val b = data[i++].toInt() and 0xFF
|
||||
tag = (tag shl 8) or b
|
||||
if (b and 0x80 == 0) break
|
||||
}
|
||||
}
|
||||
if (i >= end) return
|
||||
var len = data[i++].toInt() and 0xFF
|
||||
if (len and 0x80 != 0) {
|
||||
val n = len and 0x7F
|
||||
len = 0
|
||||
repeat(n) { if (i < end) len = (len shl 8) or (data[i++].toInt() and 0xFF) }
|
||||
}
|
||||
if (len < 0 || i + len > end) return
|
||||
if (tag == target) found.add(data.copyOfRange(i, i + len))
|
||||
if (constructed) walk(data, i, i + len, target, found)
|
||||
i += len
|
||||
}
|
||||
}
|
||||
|
||||
private fun hex(s: String): ByteArray =
|
||||
ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||
|
||||
private fun toHex(b: ByteArray): String = b.joinToString("") { "%02X".format(it) }
|
||||
}
|
||||
@@ -74,9 +74,18 @@ class AccountHistoryAdapter(
|
||||
}
|
||||
|
||||
fun setPendingTransactions(transactions: List<BankTransaction>) {
|
||||
setLeadingSections(listOf("Pending" to transactions))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets one or more labeled sections that render above the main statement list
|
||||
* (e.g. card "Outstanding" + "Unbilled"). Empty sections are skipped.
|
||||
*/
|
||||
fun setLeadingSections(sections: List<Pair<String, List<BankTransaction>>>) {
|
||||
pendingItems.clear()
|
||||
if (transactions.isNotEmpty()) {
|
||||
pendingItems.add(Item.DateHeader("Pending"))
|
||||
for ((label, transactions) in sections) {
|
||||
if (transactions.isEmpty()) continue
|
||||
pendingItems.add(Item.DateHeader(label))
|
||||
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
|
||||
@@ -84,9 +84,10 @@ class AccountHistoryFragment : Fragment() {
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
// Show default account toggle only for non-card accounts
|
||||
// Show default account toggle only for non-card, non-M-Faisa accounts.
|
||||
// M-Faisa pockets (including PayPal) cannot be set as the default transfer/QR account.
|
||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||
if (!isCard) {
|
||||
if (!isCard && account.bank != "MFAISA") {
|
||||
val store = CredentialStore(requireContext())
|
||||
adapter.showDefaultToggle = true
|
||||
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
||||
@@ -228,6 +229,13 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
|
||||
fetcher.takeCardPendingSections()?.let { (outstanding, unbilled) ->
|
||||
adapter.setLeadingSections(listOf(
|
||||
"Outstanding" to outstanding,
|
||||
"Unbilled" to unbilled
|
||||
))
|
||||
}
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = transactions.filter { it.id !in existingIds }
|
||||
|
||||
@@ -75,10 +75,12 @@ class AccountsAdapter(
|
||||
"BML" -> "Bank of Maldives"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
"MIB" -> "Maldives Islamic Bank"
|
||||
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
|
||||
else -> account.bank
|
||||
}
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
@@ -144,6 +146,7 @@ class AccountsAdapter(
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
"MFAISA" -> R.drawable.ooredoo_logo
|
||||
else -> null
|
||||
}
|
||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||
|
||||
@@ -89,6 +89,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
categories = cats.filter { it.id != "BML" }
|
||||
if (selectedDest?.isBml == false) setupCategoryDropdown()
|
||||
}
|
||||
|
||||
applyPrefillArgs()
|
||||
}
|
||||
|
||||
private fun applyPrefillArgs() {
|
||||
val args = arguments ?: return
|
||||
val bmlProfileId = args.getString(ARG_BML_PROFILE_ID)
|
||||
val accountNumber = args.getString(ARG_ACCOUNT_NUMBER)
|
||||
val recipientName = args.getString(ARG_RECIPIENT_NAME)
|
||||
val currency = args.getString(ARG_CURRENCY)
|
||||
|
||||
if (bmlProfileId != null) {
|
||||
val match = destinations.firstOrNull { it.isBml && it.bmlLoginId == bmlProfileId }
|
||||
if (match != null) {
|
||||
selectedDest = match
|
||||
binding.actvDestination.setText(match.label, false)
|
||||
updateMibOnlyVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
if (accountNumber != null) {
|
||||
binding.etAccount.setText(accountNumber)
|
||||
}
|
||||
|
||||
// Skip lookup only when we have a MIB-verified name+currency from the caller.
|
||||
if (selectedDest != null && accountNumber != null &&
|
||||
!recipientName.isNullOrBlank() && !currency.isNullOrBlank()
|
||||
) {
|
||||
val bankBic = when {
|
||||
accountNumber.matches(Regex("^9\\d{16}$")) -> "MADVMVMV"
|
||||
accountNumber.matches(Regex("^7\\d{12}$")) -> "MALBMVMV"
|
||||
else -> ""
|
||||
}
|
||||
val trnType = if (accountNumber.matches(Regex("^9\\d{16}$"))) "DOT" else "IAT"
|
||||
val validation = BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = "prefilled",
|
||||
account = accountNumber,
|
||||
originalInput = accountNumber,
|
||||
name = recipientName,
|
||||
alias = null,
|
||||
currency = currency,
|
||||
agnt = bankBic.takeIf { it.isNotBlank() }
|
||||
)
|
||||
showLookupResult(validation, accountNumber)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDestinations(): List<DestinationOption> {
|
||||
@@ -188,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun setupAccountSearch() {
|
||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
|
||||
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||
performLookup()
|
||||
true
|
||||
} else false
|
||||
}
|
||||
}
|
||||
|
||||
private fun performLookup() {
|
||||
@@ -341,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
// Auto-fill alias with existing alias or name
|
||||
if (binding.etAlias.text.isNullOrBlank()) {
|
||||
binding.etAlias.setText(validation.name)
|
||||
binding.etAlias.setText(sh.sar.basedbank.util.bmlapi.BmlDashboardParser.toTitleCase(validation.name))
|
||||
}
|
||||
binding.etCurrency.setText(validation.currency)
|
||||
|
||||
@@ -517,5 +569,24 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
companion object {
|
||||
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
|
||||
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
|
||||
|
||||
private const val ARG_BML_PROFILE_ID = "bml_profile_id"
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
private const val ARG_RECIPIENT_NAME = "recipient_name"
|
||||
private const val ARG_CURRENCY = "currency"
|
||||
|
||||
fun newInstance(
|
||||
bmlProfileId: String? = null,
|
||||
accountNumber: String? = null,
|
||||
recipientName: String? = null,
|
||||
currency: String? = null
|
||||
) = AddContactSheetFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
if (bmlProfileId != null) putString(ARG_BML_PROFILE_ID, bmlProfileId)
|
||||
if (accountNumber != null) putString(ARG_ACCOUNT_NUMBER, accountNumber)
|
||||
if (recipientName != null) putString(ARG_RECIPIENT_NAME, recipientName)
|
||||
if (currency != null) putString(ARG_CURRENCY, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun lookupMerchant(qrUrl: String) {
|
||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val session = app.anyBmlSession() ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.os.SystemClock
|
||||
import android.view.View
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* "Tap card to verify" animation: a bank card swings onto the back of a phone, NFC waves
|
||||
* ripple out from the contact point, then it lifts away and repeats. Has reading / success /
|
||||
* error states so the fragment can reflect what the reader is doing.
|
||||
*/
|
||||
class CardVerifyAnimationView(context: Context) : View(context) {
|
||||
|
||||
enum class State { WAITING, READING, SUCCESS, ERROR }
|
||||
|
||||
private var state = State.WAITING
|
||||
private var stateStart = SystemClock.uptimeMillis()
|
||||
private var label: String = ""
|
||||
|
||||
/** Text shown under the animation while waiting (and restored after an error). */
|
||||
var waitingLabel: String = ""
|
||||
set(value) { field = value; if (state == State.WAITING) label = value; invalidate() }
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
|
||||
private val rect = RectF()
|
||||
private val path = Path()
|
||||
private val easeInOut = AccelerateDecelerateInterpolator()
|
||||
private val overshoot = OvershootInterpolator(2.2f)
|
||||
|
||||
// Drives redraws only; all motion is derived from elapsed time in the current state.
|
||||
private val ticker = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 1000
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
addUpdateListener { invalidate() }
|
||||
}
|
||||
|
||||
private val revertToWaiting = Runnable { setState(State.WAITING) }
|
||||
|
||||
fun setState(newState: State, text: String? = null) {
|
||||
removeCallbacks(revertToWaiting)
|
||||
state = newState
|
||||
stateStart = SystemClock.uptimeMillis()
|
||||
label = text ?: if (newState == State.WAITING) waitingLabel else label
|
||||
if (newState == State.ERROR) postDelayed(revertToWaiting, ERROR_HOLD_MS)
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onAttachedToWindow() {
|
||||
super.onAttachedToWindow()
|
||||
ticker.start()
|
||||
}
|
||||
|
||||
override fun onDetachedFromWindow() {
|
||||
ticker.cancel()
|
||||
removeCallbacks(revertToWaiting)
|
||||
super.onDetachedFromWindow()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val w = width.toFloat(); val h = height.toFloat()
|
||||
if (w <= 0f || h <= 0f) return
|
||||
val dp = resources.displayMetrics.density
|
||||
|
||||
val colorOnSurface = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, 0xFF000000.toInt())
|
||||
val colorPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, 0xFF3F51B5.toInt())
|
||||
val colorOnPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimary, 0xFFFFFFFF.toInt())
|
||||
val colorSurfaceVariant = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, 0xFFDDDDDD.toInt())
|
||||
val colorError = MaterialColors.getColor(this, com.google.android.material.R.attr.colorError, 0xFFB3261E.toInt())
|
||||
|
||||
// Artwork is laid out in a DESIGN_W x DESIGN_H dp box, scaled to fit the available area.
|
||||
val textArea = 36 * dp
|
||||
val scale = min(min(w / (DESIGN_W * dp), (h - textArea) / (DESIGN_H * dp)), 1.3f).coerceAtLeast(0.3f)
|
||||
val u = dp * scale
|
||||
val cx = w / 2f
|
||||
val top = ((h - textArea) - DESIGN_H * u) / 2f
|
||||
|
||||
val elapsed = SystemClock.uptimeMillis() - stateStart
|
||||
|
||||
// ── Card motion: 0 = resting away from phone, 1 = held on phone ─────────
|
||||
val contact = when (state) {
|
||||
State.WAITING -> {
|
||||
val p = (elapsed % CYCLE_MS) / CYCLE_MS.toFloat()
|
||||
when {
|
||||
p < 0.35f -> easeInOut.getInterpolation(p / 0.35f)
|
||||
p < 0.70f -> 1f
|
||||
p < 1.00f -> 1f - easeInOut.getInterpolation((p - 0.70f) / 0.30f)
|
||||
else -> 0f
|
||||
}
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
val shake = if (state == State.ERROR && elapsed < 500)
|
||||
sin(elapsed / 500f * 6 * PI).toFloat() * (1f - elapsed / 500f) * 8 * u else 0f
|
||||
|
||||
// Phone
|
||||
val phoneW = 64 * u; val phoneH = 112 * u
|
||||
val phoneL = cx - phoneW / 2f; val phoneT = top + 44 * u
|
||||
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||
rect.set(phoneL, phoneT, phoneL + phoneW, phoneT + phoneH)
|
||||
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = colorOnSurface
|
||||
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
|
||||
// Camera bump (we're looking at the back of the phone)
|
||||
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 60
|
||||
rect.set(phoneL + 8 * u, phoneT + 8 * u, phoneL + 26 * u, phoneT + 30 * u)
|
||||
canvas.drawRoundRect(rect, 5 * u, 5 * u, paint)
|
||||
paint.alpha = 255
|
||||
|
||||
// Contact point where the NFC antenna sits
|
||||
val touchX = cx; val touchY = phoneT + phoneH * 0.42f
|
||||
|
||||
// ── NFC waves (behind the card) ────────────────────────────────────────
|
||||
val waveStrength = when (state) {
|
||||
State.WAITING -> ((contact - 0.85f) / 0.15f).coerceIn(0f, 1f)
|
||||
State.READING -> 1f
|
||||
else -> 0f
|
||||
}
|
||||
if (waveStrength > 0f) {
|
||||
val period = if (state == State.READING) 700f else 1100f
|
||||
val base = (elapsed % period.toLong()) / period
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * u
|
||||
for (i in 0..2) {
|
||||
val p = (base + i / 3f) % 1f
|
||||
val r = 58 * u + p * 46 * u
|
||||
paint.color = colorPrimary
|
||||
paint.alpha = ((1f - p) * 220 * waveStrength).toInt().coerceIn(0, 255)
|
||||
rect.set(touchX - r, touchY - r * 0.72f, touchX + r, touchY + r * 0.72f)
|
||||
canvas.drawOval(rect, paint)
|
||||
}
|
||||
paint.alpha = 255
|
||||
}
|
||||
|
||||
// ── Card ───────────────────────────────────────────────────────────────
|
||||
val cardW = 104 * u; val cardH = 66 * u
|
||||
val restX = cx + 58 * u; val restY = top + 48 * u
|
||||
val cardCx = restX + (touchX - restX) * contact + shake
|
||||
val cardCy = restY + (touchY - restY) * contact
|
||||
val rotation = 18f * (1f - contact)
|
||||
val lift = 1f + 0.08f * (1f - contact)
|
||||
|
||||
canvas.save()
|
||||
canvas.translate(cardCx, cardCy)
|
||||
canvas.rotate(rotation)
|
||||
canvas.scale(lift, lift)
|
||||
|
||||
// Same flat look as the phone: surface-variant body, on-surface outline, primary tint for the chip
|
||||
val outline = if (state == State.ERROR) colorError else colorOnSurface
|
||||
rect.set(-cardW / 2, -cardH / 2, cardW / 2, cardH / 2)
|
||||
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = outline
|
||||
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
|
||||
|
||||
// Chip
|
||||
rect.set(-cardW / 2 + 12 * u, -9 * u, -cardW / 2 + 30 * u, 5 * u)
|
||||
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
|
||||
paint.alpha = 255
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * u; paint.color = outline
|
||||
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
|
||||
canvas.drawLine(rect.left, rect.centerY(), rect.right, rect.centerY(), paint)
|
||||
|
||||
// Contactless symbol on the card
|
||||
paint.strokeWidth = 1.8f * u; paint.strokeCap = Paint.Cap.ROUND
|
||||
for (i in 0..2) {
|
||||
val r = (5 + i * 4.5f) * u
|
||||
rect.set(cardW / 2 - 28 * u - r, -14 * u - r, cardW / 2 - 28 * u + r, -14 * u + r)
|
||||
canvas.drawArc(rect, -45f, 90f, false, paint)
|
||||
}
|
||||
// Number + name placeholders
|
||||
paint.strokeWidth = 3f * u; paint.alpha = 150
|
||||
for (g in 0..3) {
|
||||
val x = -cardW / 2 + 12 * u + g * 21 * u
|
||||
canvas.drawLine(x, 16 * u, x + 15 * u, 16 * u, paint)
|
||||
}
|
||||
paint.alpha = 100; paint.strokeWidth = 2.5f * u
|
||||
canvas.drawLine(-cardW / 2 + 12 * u, 26 * u, -cardW / 2 + 48 * u, 26 * u, paint)
|
||||
paint.alpha = 255; paint.strokeCap = Paint.Cap.BUTT
|
||||
canvas.restore()
|
||||
|
||||
// ── Success badge ──────────────────────────────────────────────────────
|
||||
if (state == State.SUCCESS) {
|
||||
val t = (elapsed / 450f).coerceIn(0f, 1f)
|
||||
val badgeR = 22 * u * overshoot.getInterpolation(t)
|
||||
val bx = touchX + cardW / 2 - 6 * u; val by = touchY - cardH / 2 + 4 * u
|
||||
paint.style = Paint.Style.FILL; paint.color = colorPrimary
|
||||
canvas.drawCircle(bx, by, badgeR, paint)
|
||||
val checkT = ((elapsed - 200) / 350f).coerceIn(0f, 1f)
|
||||
if (checkT > 0f) {
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3.5f * u
|
||||
paint.strokeCap = Paint.Cap.ROUND; paint.color = colorOnPrimary
|
||||
val x0 = bx - 9 * u; val y0 = by
|
||||
val x1 = bx - 3 * u; val y1 = by + 7 * u
|
||||
val x2 = bx + 10 * u; val y2 = by - 7 * u
|
||||
path.reset(); path.moveTo(x0, y0)
|
||||
if (checkT < 0.4f) {
|
||||
val k = checkT / 0.4f
|
||||
path.lineTo(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k)
|
||||
} else {
|
||||
val k = (checkT - 0.4f) / 0.6f
|
||||
path.lineTo(x1, y1); path.lineTo(x1 + (x2 - x1) * k, y1 + (y2 - y1) * k)
|
||||
}
|
||||
canvas.drawPath(path, paint)
|
||||
paint.strokeCap = Paint.Cap.BUTT
|
||||
}
|
||||
}
|
||||
|
||||
// ── Label ──────────────────────────────────────────────────────────────
|
||||
textPaint.textSize = 16 * dp
|
||||
textPaint.color = if (state == State.ERROR) colorError else colorOnSurface
|
||||
textPaint.alpha = when (state) {
|
||||
State.WAITING -> (170 + 60 * sin(elapsed / 600.0).toFloat()).toInt().coerceIn(0, 255)
|
||||
else -> 230
|
||||
}
|
||||
canvas.drawText(label, cx, h - textArea / 2f + textPaint.textSize / 3f, textPaint)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DESIGN_W = 240f
|
||||
private const val DESIGN_H = 170f
|
||||
private const val CYCLE_MS = 2600L
|
||||
private const val ERROR_HOLD_MS = 1800L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.SheetContactDetailsBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
|
||||
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private var _binding: SheetContactDetailsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private val contactsFragment get() = parentFragment as? ContactsFragment
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val args = requireArguments()
|
||||
val contactId = args.getString(ARG_CONTACT_ID)
|
||||
val profileId = args.getString(ARG_PROFILE_ID)
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||
val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId }
|
||||
?.let { ContactListParser.from(it) }
|
||||
if (contact == null) dismissAllowingStateLoss() else bind(contact)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bind(contact: ContactDisplay) {
|
||||
val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2
|
||||
val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||
binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx))
|
||||
|
||||
binding.tvName.text = contact.name
|
||||
|
||||
binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE
|
||||
// QR stays visible for every contact; greyed out where PayMV QR isn't supported yet
|
||||
val canQr = canShowPayMvQr(contact)
|
||||
binding.btnQr.isEnabled = canQr
|
||||
binding.tvQrLabel.alpha = if (canQr) 1f else 0.38f
|
||||
binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE
|
||||
binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE
|
||||
|
||||
binding.btnTransfer.setOnClickListener {
|
||||
val parent = contactsFragment
|
||||
dismiss()
|
||||
parent?.openTransfer(contact)
|
||||
}
|
||||
binding.btnQr.setOnClickListener {
|
||||
val activity = requireActivity() as HomeActivity
|
||||
val bank = contactBank(contact) ?: return@setOnClickListener
|
||||
val qrName = contact.realName.ifBlank { contact.name }
|
||||
dismiss()
|
||||
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
|
||||
}
|
||||
binding.btnEdit.setOnClickListener {
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnDelete.setOnClickListener {
|
||||
val parent = contactsFragment
|
||||
dismiss()
|
||||
parent?.confirmDelete(contact)
|
||||
}
|
||||
|
||||
binding.tvAccount.text = contact.accountNumber
|
||||
val copy = View.OnClickListener { copyAccount(contact.accountNumber) }
|
||||
binding.rowAccount.setOnClickListener(copy)
|
||||
binding.btnCopyAccount.setOnClickListener(copy)
|
||||
binding.btnShareAccount.setOnClickListener {
|
||||
shareAccount(contact.realName.ifBlank { contact.name }, contact.accountNumber)
|
||||
}
|
||||
|
||||
val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name
|
||||
binding.tvRealName.text = contact.realName
|
||||
binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE
|
||||
|
||||
binding.tvCurrency.text = contact.currency ?: ""
|
||||
binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE
|
||||
|
||||
binding.tvBank.text = contact.bankName ?: ""
|
||||
binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE
|
||||
val bankLogo = bankLogoRes(contact)
|
||||
if (bankLogo != null) {
|
||||
binding.ivBankIcon.setImageResource(bankLogo)
|
||||
binding.ivBankIcon.imageTintList = null
|
||||
}
|
||||
|
||||
binding.sourceSection.visibility = View.GONE
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val appContext = requireContext().applicationContext
|
||||
val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) }
|
||||
val b = _binding ?: return@launch
|
||||
if (source == null) return@launch
|
||||
b.ivSourceIcon.setImageResource(source.logoRes)
|
||||
b.tvSource.text = source.name
|
||||
b.tvSourceBank.setText(source.bankNameRes)
|
||||
b.sourceSection.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
/** Which bank the contact's account is at ("BML" / "MIB"); null when we can't tell. */
|
||||
private fun contactBank(contact: ContactDisplay): String? = when {
|
||||
contact.network == TransferNetwork.MIB -> "MIB"
|
||||
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> "BML"
|
||||
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> "MIB"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
|
||||
private fun bankLogoRes(contact: ContactDisplay): Int? = when (contactBank(contact)) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* PayMV QR is only offered for contacts whose account is at BML for now.
|
||||
* TODO: enable for MIB and other banks once their PayMV QR payloads are verified —
|
||||
* PayMvQrFragment.forContact already takes the bank, so this check is the only gate.
|
||||
*/
|
||||
private fun canShowPayMvQr(contact: ContactDisplay): Boolean =
|
||||
contactBank(contact) == "BML" &&
|
||||
contact.currency?.contains("USD", ignoreCase = true) != true // PayMV QR is MVR only
|
||||
|
||||
private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String)
|
||||
|
||||
/** Which of the user's own bank profiles this contact is saved under. */
|
||||
private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? {
|
||||
val store = CredentialStore(context)
|
||||
return when (contact.network) {
|
||||
TransferNetwork.BML -> {
|
||||
// BML contacts carry either the loginId or a BML profileId
|
||||
val loginId = store.getBmlLoginIds().firstOrNull { loginId ->
|
||||
loginId == contact.profileId ||
|
||||
store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId }
|
||||
} ?: return null
|
||||
val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||
val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name)
|
||||
?: return null
|
||||
SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name)
|
||||
}
|
||||
TransferNetwork.FAHIPAY -> {
|
||||
val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId ->
|
||||
store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||
} ?: return null
|
||||
SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name)
|
||||
}
|
||||
else -> {
|
||||
val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId ->
|
||||
store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||
} ?: return null
|
||||
SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shareAccount(accountName: String, account: String) {
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, "$accountName\n$account")
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, getString(R.string.contact_share_account)))
|
||||
}
|
||||
|
||||
private fun copyAccount(account: String) {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||
Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_CONTACT_ID = "contact_id"
|
||||
private const val ARG_PROFILE_ID = "profile_id"
|
||||
|
||||
fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply {
|
||||
arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||
|
||||
class ContactPickerAdapter(
|
||||
private val imageCache: MutableMap<String, Bitmap>,
|
||||
private val onItemClick: (accountNumber: String, label: String) -> Unit,
|
||||
private val onItemClick: (accountNumber: String, label: String, categoryId: String?) -> Unit,
|
||||
private val onSameAsFrom: () -> Unit,
|
||||
private val onImageNeeded: ((hash: String) -> Unit)? = null,
|
||||
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
|
||||
@@ -33,7 +33,9 @@ class ContactPickerAdapter(
|
||||
val imageHash: String? = null,
|
||||
val inactiveReason: String? = null,
|
||||
val balance: String? = null,
|
||||
val bankLogoRes: Int? = null
|
||||
val bankLogoRes: Int? = null,
|
||||
/** The saved contact's category, for rows that are contacts; null otherwise. */
|
||||
val categoryId: String? = null
|
||||
) : PickerItem()
|
||||
}
|
||||
|
||||
@@ -125,7 +127,7 @@ class ContactPickerAdapter(
|
||||
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
|
||||
item.isSameAsFrom ->
|
||||
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
|
||||
else -> onItemClick(item.accountNumber, item.displayName)
|
||||
else -> onItemClick(item.accountNumber, item.displayName, item.categoryId)
|
||||
}
|
||||
}
|
||||
binding.root.setOnLongClickListener { view ->
|
||||
|
||||
@@ -54,7 +54,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
|
||||
ContactPickerAdapter(
|
||||
imageCache = sharedImageCache,
|
||||
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
|
||||
onItemClick = { accountNumber, label, categoryId -> handlePickerSelection(accountNumber, label, categoryId) },
|
||||
onSameAsFrom = {},
|
||||
onImageNeeded = { hash -> fetchImage(hash) },
|
||||
onItemLongClick = { accountNumber, anchor ->
|
||||
@@ -161,17 +161,22 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
}.also { it.attach() }
|
||||
}
|
||||
|
||||
private fun handlePickerSelection(accountNumber: String, label: String) {
|
||||
private fun handlePickerSelection(accountNumber: String, label: String, categoryId: String?) {
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||
categoryId?.let { bundle.putString(KEY_CATEGORY, it) }
|
||||
when {
|
||||
accountNumber.startsWith("bmlqr:") -> {
|
||||
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
||||
bundle.putString(KEY_COLOR, "#0066A1")
|
||||
}
|
||||
accountNumber.startsWith("mfaisaqr:") -> {
|
||||
bundle.putString(KEY_SUBTITLE, "M-Faisa QR Merchant")
|
||||
bundle.putString(KEY_COLOR, "#ED1C24")
|
||||
}
|
||||
account != null -> {
|
||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||
bundle.putString(KEY_COLOR, "#FE860E")
|
||||
@@ -195,6 +200,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
||||
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||
val fromIsMfaisa = fromAccount?.bank == "MFAISA"
|
||||
// TODO: when M-Faisa-supported contacts are wired up, swap this for a per-row check
|
||||
// (e.g. is the recipient also an M-Faisa wallet) instead of disabling everything.
|
||||
val mfaisaInactive = if (fromIsMfaisa) "Unsupported recipient from M-Faisa" else null
|
||||
|
||||
if (tabTag == RECENTS_TAG) {
|
||||
val recents = RecentsCache.load(requireContext())
|
||||
val filtered = if (search.isBlank()) recents else recents.filter {
|
||||
@@ -208,19 +224,16 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
subtitle = r.subtitle,
|
||||
colorHex = r.colorHex,
|
||||
isSameAsFrom = r.accountNumber == fromAccountNumber,
|
||||
imageHash = r.imageHash
|
||||
imageHash = r.imageHash,
|
||||
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
|
||||
// when the source is M-Faisa.
|
||||
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive,
|
||||
categoryId = r.contactCategory
|
||||
))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||
|
||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||
@@ -251,9 +264,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
inactiveReason = when {
|
||||
isSame -> null
|
||||
mfaisaInactive != null && acc.bank != "MFAISA" -> mfaisaInactive
|
||||
fromIsCard && acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
|
||||
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
},
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
@@ -283,10 +299,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (!isActive) acc.statusDesc
|
||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
inactiveReason = when {
|
||||
isSame -> null
|
||||
mfaisaInactive != null -> mfaisaInactive
|
||||
!isActive -> acc.statusDesc
|
||||
acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
|
||||
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
},
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
@@ -311,7 +330,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
colorHex = contact.bankColor,
|
||||
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
||||
imageHash = contact.customerImgHash,
|
||||
inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
|
||||
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc),
|
||||
categoryId = contact.benefCategoryId
|
||||
))
|
||||
}
|
||||
return items
|
||||
@@ -379,6 +399,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
const val KEY_SUBTITLE = "subtitle"
|
||||
const val KEY_COLOR = "color"
|
||||
const val KEY_IMAGE_HASH = "imageHash"
|
||||
/** The picked contact's category (`BankContact.benefCategoryId`); absent for non-contacts. */
|
||||
const val KEY_CATEGORY = "category"
|
||||
private const val ARG_FROM_ACCOUNT = "fromAccount"
|
||||
private const val RECENTS_TAG = "__recents__"
|
||||
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
|
||||
|
||||
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
|
||||
class ContactsAdapter(
|
||||
private val imageCache: MutableMap<String, Bitmap>,
|
||||
private val onImageNeeded: (hash: String) -> Unit,
|
||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
||||
private val onContactClick: (ContactDisplay) -> Unit,
|
||||
private val onTransferClick: (ContactDisplay) -> Unit
|
||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||
|
||||
@@ -66,12 +65,9 @@ class ContactsAdapter(
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
||||
}
|
||||
binding.btnEditContact.setOnClickListener {
|
||||
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnDeleteContact.setOnClickListener {
|
||||
binding.root.setOnClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
|
||||
if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
|
||||
}
|
||||
binding.root.setOnLongClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
@@ -107,37 +103,33 @@ class ContactsAdapter(
|
||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnTransferContact.visibility =
|
||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||
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) {
|
||||
binding.ivContactPhoto.setImageBitmap(photo)
|
||||
} else {
|
||||
val sizePx = binding.ivContactPhoto.context.resources
|
||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||
.coerceAtLeast(96)
|
||||
binding.ivContactPhoto.setImageBitmap(
|
||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
||||
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||
val sizePx = binding.ivContactPhoto.context.resources
|
||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||
.coerceAtLeast(96)
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.color = bgColor
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = sizePx * 0.42f
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val metrics = paint.fontMetrics
|
||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
return bm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.color = bgColor
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = sizePx * 0.42f
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val metrics = paint.fontMetrics
|
||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
return bm
|
||||
}
|
||||
|
||||
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Typeface
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.ForegroundColorSpan
|
||||
import android.text.style.StyleSpan
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
|
||||
ContactsAdapter(
|
||||
imageCache = sharedImageCache,
|
||||
onImageNeeded = { hash -> fetchImage(hash) },
|
||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||
onContactClick = { contact -> showDetails(contact) },
|
||||
onTransferClick = { contact -> openTransfer(contact) }
|
||||
).also { a ->
|
||||
a.setFilter(page.categoryId, currentSearch)
|
||||
@@ -170,21 +177,43 @@ class ContactsFragment : Fragment() {
|
||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||
}
|
||||
|
||||
private fun openTransfer(contact: ContactDisplay) {
|
||||
private fun showDetails(contact: ContactDisplay) {
|
||||
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||
}
|
||||
|
||||
internal fun openTransfer(contact: ContactDisplay) {
|
||||
val fragment = TransferFragment.newInstance(
|
||||
accountNumber = contact.accountNumber,
|
||||
displayName = contact.name,
|
||||
subtitle = contact.transferSubtitle,
|
||||
colorHex = contact.bankColor,
|
||||
imageHash = contact.imageHash
|
||||
imageHash = contact.imageHash,
|
||||
contactCategory = contact.categoryId
|
||||
)
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
}
|
||||
|
||||
private fun confirmDelete(contact: ContactDisplay) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
internal fun confirmDelete(contact: ContactDisplay) {
|
||||
val ctx = requireContext()
|
||||
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
||||
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||
|
||||
val prompt = getString(R.string.contact_delete_message, contact.name)
|
||||
val message = SpannableStringBuilder(prompt).apply {
|
||||
val start = prompt.indexOf(contact.name)
|
||||
if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
append("\n").append(contact.accountNumber)
|
||||
append("\n\n")
|
||||
val warnStart = length
|
||||
append(getString(R.string.contact_delete_warning))
|
||||
setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog)
|
||||
.setIcon(icon)
|
||||
.setTitle(R.string.contact_delete_title)
|
||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = bmlItems + mibItems
|
||||
val rank = credStore.loginRank()
|
||||
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
|
||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||
val ordered = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
|
||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
|
||||
expandedPositions.clear()
|
||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
val mibItems = mibDeals.map { Item.Mib(it) }
|
||||
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.util.CredentialStore
|
||||
|
||||
class FinancingFragment : Fragment() {
|
||||
|
||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||
|
||||
adapter.update(latestMibDeals, bmlLoans)
|
||||
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
|
||||
val order = CredentialStore(requireContext()).getLoginOrder()
|
||||
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
|
||||
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
|
||||
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
|
||||
|
||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||
|
||||
@@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (securitySet) lock()
|
||||
}
|
||||
|
||||
// ── Payment guard ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// The manifest has this activity handle theme, language, font-size and display-size changes
|
||||
// itself, because recreating it mid-payment tears down the screen waiting on the bank's
|
||||
// answer — the money can move with nothing left to say so. Those changes still need a
|
||||
// recreate to re-inflate with the new resources, so it runs straight away when nothing is in
|
||||
// flight and otherwise waits until the last payment finishes.
|
||||
|
||||
private var paymentsInFlight = 0
|
||||
private var recreatePending = false
|
||||
private var lastConfig: Configuration? = null
|
||||
|
||||
/** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */
|
||||
inner class PaymentGuard internal constructor() {
|
||||
private var ended = false
|
||||
fun end() {
|
||||
if (ended) return
|
||||
ended = true
|
||||
paymentsInFlight--
|
||||
if (paymentsInFlight == 0 && recreatePending) {
|
||||
recreatePending = false
|
||||
// Posted so a receipt screen committed in the same pass is saved with the state
|
||||
binding.root.post { recreate() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */
|
||||
fun beginPayment(owner: LifecycleOwner): PaymentGuard {
|
||||
paymentsInFlight++
|
||||
val guard = PaymentGuard()
|
||||
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onDestroy(owner: LifecycleOwner) = guard.end()
|
||||
})
|
||||
return guard
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
val previous = lastConfig
|
||||
lastConfig = Configuration(newConfig)
|
||||
// Size and orientation changes are handled in place; these need fresh resources.
|
||||
val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or
|
||||
android.content.pm.ActivityInfo.CONFIG_LOCALE or
|
||||
android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or
|
||||
android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or
|
||||
android.content.pm.ActivityInfo.CONFIG_DENSITY
|
||||
if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return
|
||||
if (paymentsInFlight > 0) recreatePending = true else recreate()
|
||||
}
|
||||
|
||||
fun lockApp() = lock()
|
||||
|
||||
fun notifyWheelLockTap() {
|
||||
@@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
setContentView(binding.root)
|
||||
lastConfig = Configuration(resources.configuration)
|
||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
|
||||
insets
|
||||
}
|
||||
|
||||
// The app bar only pads for the status bar. In landscape the navigation bar (and any
|
||||
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
|
||||
// draw underneath it, out of reach.
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
|
||||
val sides = insets.getInsets(
|
||||
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||
v.updatePadding(left = sides.left, right = sides.right)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||
val frag = when (item.itemId) {
|
||||
@@ -202,9 +266,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// Load data
|
||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) {
|
||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts
|
||||
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
||||
if (app.bmlAccounts.isNotEmpty()) {
|
||||
@@ -215,6 +279,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||
}
|
||||
if (app.mfaisaAccounts.isNotEmpty()) {
|
||||
val byLoginId = app.mfaisaAccounts.groupBy { it.loginTag.removePrefix("mfaisa_") }
|
||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveMfaisa(this, loginId, accs) }
|
||||
}
|
||||
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
@@ -238,10 +306,11 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cachedMib = AccountCache.load(this)
|
||||
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
|
||||
val cachedFinancing = FinancingCache.load(this)
|
||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||
@@ -523,9 +592,13 @@ fun applyNavLabelVisibility() {
|
||||
|
||||
private fun routeSharedQrText(text: String) {
|
||||
val store = CredentialStore(this)
|
||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
||||
sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it))
|
||||
return
|
||||
}
|
||||
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||
if (bmlTarget != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||
return
|
||||
}
|
||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||
@@ -695,17 +768,20 @@ fun applyNavLabelVisibility() {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Immediately drop accounts for logged-out logins from the displayed list
|
||||
val current = viewModel.accounts.value ?: emptyList()
|
||||
val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds()
|
||||
viewModel.accounts.value = current.filter { acc ->
|
||||
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
||||
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
||||
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
||||
if (acc.bank == "MFAISA") return@filter acc.loginTag.removePrefix("mfaisa_") in mfaisaLoginIdsForFilter
|
||||
true
|
||||
}
|
||||
autoRefresh(store)
|
||||
@@ -724,7 +800,8 @@ fun applyNavLabelVisibility() {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) return
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
hideConnectivityBanner()
|
||||
|
||||
@@ -765,47 +842,13 @@ fun applyNavLabelVisibility() {
|
||||
|
||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||
|
||||
val bmlClient = BmlAccountClient()
|
||||
// Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
|
||||
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
|
||||
for (profile in savedProfiles) {
|
||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||
if (saved == null) {
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
continue
|
||||
}
|
||||
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||
|
||||
suspend fun fetchWithSession(session: BmlSession) {
|
||||
bmlClient.checkProfile(session)
|
||||
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||
app.bmlSessions[profile.profileId] = session
|
||||
allAccounts += accounts
|
||||
}
|
||||
|
||||
suspend fun tryRefresh() {
|
||||
if (refreshToken == null) throw Exception("No refresh token")
|
||||
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||
if (newSession.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||
if (newSession.expiresAt > 0)
|
||||
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||
fetchWithSession(newSession)
|
||||
}
|
||||
|
||||
if (profile.profileId in hiddenProfiles) continue
|
||||
try {
|
||||
if (tokenKnownExpired) {
|
||||
tryRefresh()
|
||||
} else {
|
||||
try {
|
||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||
} catch (_: AuthExpiredException) {
|
||||
tryRefresh()
|
||||
}
|
||||
}
|
||||
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
|
||||
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
@@ -898,17 +941,58 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
// One async job per M-Faisa login, all run in parallel.
|
||||
// M-Faisa has no session refresh — sessions expire after ~240s — so we re-login each refresh.
|
||||
val mfaisaJobs = mfaisaLoginIds.mapNotNull { loginId ->
|
||||
val creds = store.loadMfaisaCredentials(loginId) ?: return@mapNotNull null
|
||||
loginId to async(Dispatchers.IO) {
|
||||
val loginTag = "mfaisa_$loginId"
|
||||
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this@HomeActivity)
|
||||
try {
|
||||
flow.fetchSubscriber(creds.msisdn)
|
||||
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||
val accounts = sh.sar.basedbank.api.mfaisa.MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||
val app = application as BasedBankApp
|
||||
app.mfaisaSessions[loginId] = result.session
|
||||
store.saveMfaisaUserProfile(
|
||||
loginId,
|
||||
CredentialStore.MfaisaUserProfile(
|
||||
name = result.profile.name,
|
||||
email = result.profile.email,
|
||||
mdnId = result.profile.mdnId,
|
||||
subscriberId = result.profile.subscriberId,
|
||||
walletId = result.profile.walletId,
|
||||
roleId = result.profile.roleId,
|
||||
offerId = result.profile.offerId
|
||||
)
|
||||
)
|
||||
AccountCache.saveMfaisa(this@HomeActivity, loginId, accounts)
|
||||
accounts
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
} catch (e: BankServerException) {
|
||||
refreshErrors.add("SERVER:${e.bankName}")
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
} catch (_: Exception) {
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||
val mibAccounts = mibResults.flatMap { it.second }
|
||||
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||
val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() }
|
||||
|
||||
val app = application as BasedBankApp
|
||||
app.mibAccounts = mibAccounts
|
||||
AccountCache.save(this@HomeActivity, mibAccounts)
|
||||
app.bmlAccounts = bmlAccounts
|
||||
app.fahipayAccounts = fahipayAccounts
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
||||
app.mfaisaAccounts = mfaisaAccounts
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts + mfaisaAccounts).filterVisibleAccounts())
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
|
||||
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||
@@ -935,15 +1019,17 @@ fun applyNavLabelVisibility() {
|
||||
refreshBmlLoanDetails()
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles)
|
||||
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Filters accounts whose profileId the user has hidden in settings. */
|
||||
/** Filters accounts whose profileId or account number the user has hidden in settings. */
|
||||
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
|
||||
return filter { acc ->
|
||||
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
|
||||
when (acc.bank) {
|
||||
"MIB" -> {
|
||||
val loginId = acc.loginTag.removePrefix("mib_")
|
||||
@@ -955,6 +1041,11 @@ fun applyNavLabelVisibility() {
|
||||
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||
hidden.isEmpty() || acc.profileId !in hidden
|
||||
}
|
||||
"MFAISA" -> {
|
||||
val loginId = acc.loginTag.removePrefix("mfaisa_")
|
||||
val hidden = store.getHiddenMfaisaPocketIds(loginId)
|
||||
hidden.isEmpty() || acc.accountNumber !in hidden
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
@@ -967,10 +1058,140 @@ fun applyNavLabelVisibility() {
|
||||
return filter { it.profileId !in hidden }
|
||||
}
|
||||
|
||||
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
||||
fun applyProfileVisibility() {
|
||||
val current = viewModel.accounts.value ?: return
|
||||
viewModel.accounts.value = current.filterVisibleAccounts()
|
||||
/** Drops MIB cards belonging to profiles the user has hidden. */
|
||||
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
return filter { card ->
|
||||
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
|
||||
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
|
||||
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
|
||||
*/
|
||||
fun applyVisibility() {
|
||||
val app = application as BasedBankApp
|
||||
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
|
||||
viewModel.mibCards.value?.let { cards ->
|
||||
val visible = cards.filterVisibleCards()
|
||||
viewModel.mibCards.value = visible
|
||||
CardsCache.save(this, visible)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
|
||||
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
|
||||
if (profileIds.isEmpty()) return
|
||||
val app = application as BasedBankApp
|
||||
val loginTag = "mib_$loginId"
|
||||
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
lifecycleScope.launch {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val session = app.mibSessions[loginId]
|
||||
if (session != null && enabled.isNotEmpty()) {
|
||||
try {
|
||||
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
|
||||
if (accounts.isNotEmpty()) {
|
||||
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
// Session expired — log this one login in again (it now includes the unhidden profiles)
|
||||
val store = CredentialStore(this@HomeActivity)
|
||||
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
|
||||
try {
|
||||
val flow = MibLoginFlow(store)
|
||||
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||
app.mibSessions[loginId] = flow.lastSession!!
|
||||
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||
app.mibLoginFlows[loginId] = flow
|
||||
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
if (fresh == null) return@launch
|
||||
app.mibAccounts = fresh
|
||||
AccountCache.save(this@HomeActivity, fresh)
|
||||
applyVisibility()
|
||||
val session = app.mibSessions[loginId] ?: return@launch
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
|
||||
// Financing deals carry no profile id, so the login's visible profiles are fetched together
|
||||
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
|
||||
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
|
||||
if (profileIds.isEmpty()) return
|
||||
val app = application as BasedBankApp
|
||||
val store = CredentialStore(this)
|
||||
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
|
||||
if (enabled.isEmpty()) return
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
lifecycleScope.launch {
|
||||
val fetched = withContext(Dispatchers.IO) {
|
||||
enabled.mapNotNull { profile ->
|
||||
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
if (fetched.isEmpty()) return@launch
|
||||
val fetchedIds = fetched.map { it.first }.toSet()
|
||||
val loginTag = "bml_$loginId"
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
|
||||
fetched.flatMap { it.second }
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
|
||||
applyVisibility()
|
||||
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
|
||||
refreshBmlLoanDetails()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
|
||||
* Returns null when the profile has no saved session; throws on network/server errors.
|
||||
*/
|
||||
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
|
||||
val app = application as BasedBankApp
|
||||
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
|
||||
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||
val bmlClient = BmlAccountClient()
|
||||
|
||||
fun fetchWithSession(session: BmlSession): List<BankAccount> {
|
||||
bmlClient.checkProfile(session)
|
||||
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
|
||||
app.bmlSessions[profile.profileId] = session
|
||||
return accounts
|
||||
}
|
||||
|
||||
fun tryRefresh(): List<BankAccount> {
|
||||
if (refreshToken == null) throw Exception("No refresh token")
|
||||
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||
if (newSession.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||
if (newSession.expiresAt > 0)
|
||||
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||
return fetchWithSession(newSession)
|
||||
}
|
||||
|
||||
return if (tokenKnownExpired) {
|
||||
tryRefresh()
|
||||
} else {
|
||||
try {
|
||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||
} catch (_: AuthExpiredException) {
|
||||
tryRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshBmlLimits(session: BmlSession) {
|
||||
@@ -1204,7 +1425,7 @@ fun applyNavLabelVisibility() {
|
||||
val app = application as BasedBankApp
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles)
|
||||
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1229,7 +1450,8 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
if (cards.isNotEmpty()) {
|
||||
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
||||
existing.removeAll { it.loginTag == "mib_$loginId" }
|
||||
val fetchedIds = profiles.map { it.profileId }.toSet()
|
||||
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
|
||||
existing += cards
|
||||
viewModel.mibCards.postValue(existing)
|
||||
CardsCache.save(this@HomeActivity, existing)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
@@ -9,14 +10,21 @@ 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.util.CredentialStore
|
||||
|
||||
sealed class CardItem {
|
||||
data class Mib(val card: MibCard) : CardItem()
|
||||
data class Bml(val account: BankAccount) : CardItem()
|
||||
abstract val loginTag: String
|
||||
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
|
||||
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
|
||||
}
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
|
||||
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
|
||||
override fun setValue(value: List<BankAccount>?) {
|
||||
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||
}
|
||||
}
|
||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||
/** BML loan details keyed by account internalId. */
|
||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||
|
||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
||||
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
|
||||
override fun setValue(value: List<MibCard>?) {
|
||||
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||
}
|
||||
}
|
||||
|
||||
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
|
||||
|
||||
/** Re-applies the login order after the user changes it. */
|
||||
fun resortByLoginOrder() {
|
||||
accounts.value = accounts.value
|
||||
mibCards.value = mibCards.value
|
||||
}
|
||||
|
||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||
|
||||
@@ -36,4 +56,7 @@ class HomeViewModel : ViewModel() {
|
||||
* for HTTP 5xx server errors from specific banks.
|
||||
*/
|
||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
||||
|
||||
/** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */
|
||||
var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft()
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -18,13 +29,23 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
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.databinding.DialogOtpExportSeedBinding
|
||||
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
|
||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.OtpauthParser
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
class OtpFragment : Fragment() {
|
||||
@@ -32,7 +53,35 @@ class OtpFragment : Fragment() {
|
||||
private var _binding: FragmentOtpBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private data class OtpEntry(val label: String, val seed: String)
|
||||
private data class OtpEntry(
|
||||
val bank: String, val loginId: String, val account: String, val name: String?, val seed: String
|
||||
)
|
||||
|
||||
private val entries = mutableListOf<OtpEntry>()
|
||||
private var adapter: OtpAdapter? = null
|
||||
|
||||
/** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */
|
||||
private var scanTarget: android.widget.EditText? = 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 target = scanTarget ?: return@registerForActivityResult
|
||||
val found = OtpauthParser.parse(raw)
|
||||
when {
|
||||
found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||
found.size == 1 -> target.setText(found[0].secret)
|
||||
else -> {
|
||||
val labels = found.map { e ->
|
||||
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||
}.toTypedArray()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Choose account")
|
||||
.setItems(labels) { _, i -> target.setText(found[i].secret) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||
@@ -45,18 +94,19 @@ class OtpFragment : Fragment() {
|
||||
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.b.tvOtpLabel.text = entries[position].label
|
||||
update(holder.b, entries[position].seed)
|
||||
holder.b.root.setOnClickListener {
|
||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
||||
if (code.isNotEmpty()) {
|
||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
val entry = entries[position]
|
||||
val b = holder.b
|
||||
b.tvOtpBank.text = entry.bank
|
||||
b.tvOtpLabel.text = entry.name ?: "Authenticator"
|
||||
b.ivBankLogo.setImageResource(
|
||||
if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
|
||||
)
|
||||
update(b, entry.seed)
|
||||
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
// Long-press opens the seed menu (export / update)
|
||||
b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true }
|
||||
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
|
||||
}
|
||||
|
||||
fun tick() {
|
||||
@@ -71,9 +121,196 @@ class OtpFragment : Fragment() {
|
||||
val secondsInPeriod = (epochSeconds % 30).toInt()
|
||||
val remaining = 30 - secondsInPeriod
|
||||
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
||||
val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" }
|
||||
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
||||
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
|
||||
b.otpProgress.progress = remaining
|
||||
b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}"
|
||||
b.tvOtpCountdown.text = remaining.toString()
|
||||
|
||||
// Turn the ring and code red in the last few seconds of the window
|
||||
val expiring = remaining <= 5
|
||||
val accent = MaterialColors.getColor(b.root,
|
||||
if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary)
|
||||
b.otpProgress.setIndicatorColor(accent)
|
||||
b.tvOtpCode.setTextColor(accent)
|
||||
b.tvOtpCountdown.setTextColor(
|
||||
if (expiring) accent
|
||||
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSeedMenu(anchor: View, position: Int) {
|
||||
if (position == RecyclerView.NO_POSITION) return
|
||||
val popup = PopupMenu(anchor.context, anchor)
|
||||
popup.menu.add(0, 1, 0, "Export seed")
|
||||
popup.menu.add(0, 2, 1, "Update seed")
|
||||
popup.setOnMenuItemClickListener { item ->
|
||||
val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false
|
||||
when (item.itemId) {
|
||||
1 -> showExportDialog(entry)
|
||||
2 -> showUpdateDialog(position)
|
||||
}
|
||||
true
|
||||
}
|
||||
popup.show()
|
||||
}
|
||||
|
||||
private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}"
|
||||
|
||||
// ── Export ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun showExportDialog(entry: OtpEntry) {
|
||||
val ctx = requireContext()
|
||||
val d = DialogOtpExportSeedBinding.inflate(layoutInflater)
|
||||
val uri = OtpauthParser.buildUri(entry.bank, entry.seed)
|
||||
d.tvSeed.text = entry.seed.chunked(4).joinToString(" ")
|
||||
renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) }
|
||||
d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") }
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(entryTitle(entry))
|
||||
.setView(d.root)
|
||||
.setPositiveButton("Done", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun renderQr(content: String, size: Int): Bitmap? = try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||
)
|
||||
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE }
|
||||
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
/** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */
|
||||
private fun copySensitive(context: Context, text: String, message: String) {
|
||||
val clip = ClipData.newPlainText("OTP seed", text)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
clip.description.extras = PersistableBundle().apply {
|
||||
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||
}
|
||||
}
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(clip)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun showUpdateDialog(position: Int) {
|
||||
val entry = entries.getOrNull(position) ?: return
|
||||
val ctx = requireContext()
|
||||
val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater)
|
||||
d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " +
|
||||
"can't be recovered. If you might still need it, export it first."
|
||||
|
||||
var newSeed: String? = null
|
||||
// Same behaviour as the sign-in screen's preview card
|
||||
val preview = d.cardOtp
|
||||
preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") }
|
||||
fun refreshPreview() {
|
||||
val seed = newSeed
|
||||
try {
|
||||
if (seed == null) throw IllegalArgumentException()
|
||||
preview.tvOtpCode.text = Totp.generate(seed)
|
||||
preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
preview.otpTimer.max = 30
|
||||
preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||
preview.root.visibility = View.VISIBLE
|
||||
} catch (_: Exception) {
|
||||
preview.root.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle("Update seed · ${entry.bank}")
|
||||
.setView(d.root)
|
||||
.setPositiveButton("Replace", null)
|
||||
.setNegativeButton("Cancel", null)
|
||||
.create()
|
||||
|
||||
fun validate() {
|
||||
val raw = d.etNewSeed.text?.toString().orEmpty()
|
||||
newSeed = OtpauthParser.resolveSecret(raw)
|
||||
d.tilNewSeed.error = when {
|
||||
raw.isBlank() -> null
|
||||
newSeed == null -> "Not a valid TOTP seed"
|
||||
newSeed == entry.seed -> "This is already the current seed"
|
||||
else -> null
|
||||
}
|
||||
if (newSeed == entry.seed) newSeed = null
|
||||
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null
|
||||
refreshPreview()
|
||||
}
|
||||
|
||||
d.etNewSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) = validate()
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
d.btnScanNewSeed.setOnClickListener {
|
||||
scanTarget = d.etNewSeed
|
||||
qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
val ticker = viewLifecycleOwner.lifecycleScope.launch {
|
||||
while (isActive) { refreshPreview(); delay(1_000) }
|
||||
}
|
||||
dialog.setOnDismissListener {
|
||||
ticker.cancel()
|
||||
if (scanTarget === d.etNewSeed) scanTarget = null
|
||||
}
|
||||
dialog.setOnShowListener {
|
||||
val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||
replace.isEnabled = false
|
||||
replace.setOnClickListener {
|
||||
val seed = newSeed ?: return@setOnClickListener
|
||||
confirmReplace(entry) {
|
||||
saveSeed(position, seed)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Delete old seed?")
|
||||
.setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " +
|
||||
"recovered afterwards.")
|
||||
.setPositiveButton("Replace") { _, _ -> onConfirm() }
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun saveSeed(position: Int, seed: String) {
|
||||
val entry = entries.getOrNull(position) ?: return
|
||||
val store = CredentialStore(requireContext())
|
||||
when (entry.bank) {
|
||||
"MIB" -> {
|
||||
store.updateMibOtpSeed(entry.loginId, seed)
|
||||
// The live flow keeps the seed in memory for silent re-login
|
||||
(requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed)
|
||||
}
|
||||
"BML" -> store.updateBmlOtpSeed(entry.loginId, seed)
|
||||
}
|
||||
entries[position] = entry.copy(seed = seed)
|
||||
adapter?.notifyItemChanged(position)
|
||||
Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||
val code = text.toString().replace(" ", "")
|
||||
if (code.isEmpty() || code.contains('-')) return
|
||||
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,21 +323,27 @@ class OtpFragment : Fragment() {
|
||||
val store = CredentialStore(requireContext())
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
val entries = mutableListOf<OtpEntry>()
|
||||
val rank = store.loginRank()
|
||||
val tagged = mutableListOf<Pair<String, OtpEntry>>()
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||
val name = store.loadMibFullName(loginId)
|
||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("mib", loginId) to
|
||||
OtpEntry("MIB", loginId, creds.username, name, creds.otpSeed))
|
||||
}
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
||||
tagged.add(CredentialStore.loginKey("bml", loginId) to
|
||||
OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||
}
|
||||
entries.clear()
|
||||
entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second })
|
||||
|
||||
val adapter = OtpAdapter(entries)
|
||||
val adapter = OtpAdapter(entries).also { this.adapter = it }
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
// Fetch real names in background if not yet cached, then refresh labels
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
@@ -120,9 +363,8 @@ class OtpFragment : Fragment() {
|
||||
mobile = profile.mobile,
|
||||
enrolled = profile.enrolled
|
||||
))
|
||||
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 }
|
||||
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,9 +383,8 @@ class OtpFragment : Fragment() {
|
||||
idCard = info.idCard,
|
||||
birthdate = info.birthdate
|
||||
))
|
||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
||||
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +406,8 @@ class OtpFragment : Fragment() {
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
adapter = null
|
||||
scanTarget = null
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,15 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.text.TextPaint
|
||||
import android.text.TextUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
@@ -48,6 +51,8 @@ class PayMvQrFragment : Fragment() {
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var selectedAccount: BankAccount? = null
|
||||
/** Set when opened for a saved contact: the QR is for their account, not one of ours. */
|
||||
private var contactTarget: QrTarget? = null
|
||||
private var generatedBitmap: Bitmap? = null
|
||||
private var generateJob: Job? = null
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
@@ -59,7 +64,28 @@ class PayMvQrFragment : Fragment() {
|
||||
return binding.root
|
||||
}
|
||||
|
||||
/** Who the QR pays into — one of our own accounts, or a contact's. */
|
||||
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
|
||||
|
||||
private fun currentTarget(): QrTarget? = contactTarget
|
||||
?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) }
|
||||
|
||||
/** Name printed on the card and put in the payload (tag 59). */
|
||||
private fun qrHolderName(account: BankAccount): String = when {
|
||||
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
|
||||
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
|
||||
?: CredentialStore(requireContext())
|
||||
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
|
||||
?.fullName?.takeIf { it.isNotBlank() }
|
||||
?: account.accountBriefName
|
||||
else -> account.accountBriefName
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
contactTarget = arguments?.let { args ->
|
||||
val number = args.getString(ARG_ACCOUNT_NUMBER) ?: return@let null
|
||||
QrTarget(number, args.getString(ARG_ACCOUNT_NAME).orEmpty(), args.getString(ARG_BANK).orEmpty())
|
||||
}
|
||||
val basePaddingBottom = view.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||
@@ -68,7 +94,15 @@ class PayMvQrFragment : Fragment() {
|
||||
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||
insets
|
||||
}
|
||||
setupDropdown()
|
||||
if (contactTarget != null) {
|
||||
// Not our account, so no account picker and our phone number doesn't belong on it
|
||||
binding.tilAccount.visibility = View.GONE
|
||||
binding.layoutIncludePhone.visibility = View.GONE
|
||||
binding.switchIncludePhone.isChecked = false
|
||||
scheduleGenerate()
|
||||
} else {
|
||||
setupDropdown()
|
||||
}
|
||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
||||
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
||||
@@ -83,6 +117,7 @@ class PayMvQrFragment : Fragment() {
|
||||
val eligible = accounts.filter {
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
|
||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||
}
|
||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||
@@ -118,24 +153,26 @@ class PayMvQrFragment : Fragment() {
|
||||
}
|
||||
|
||||
private suspend fun generateQr() {
|
||||
val account = selectedAccount ?: return
|
||||
val acquirer = when (account.bank) {
|
||||
val target = currentTarget() ?: return
|
||||
val acquirer = when (target.bank) {
|
||||
"BML" -> "MALBMVMV"
|
||||
"MIB" -> "MADVMVMV"
|
||||
"FAHIPAY" -> "FAHIMVMV"
|
||||
else -> "MADVMVMV"
|
||||
}
|
||||
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||
?.replace(",", "")
|
||||
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
|
||||
val amountFormatted = amountRaw
|
||||
?.toDoubleOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?.let { "%.2f".format(it) }
|
||||
// BML shows the amount on the card as typed (no forced decimals)
|
||||
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
|
||||
|
||||
val ctx = requireContext()
|
||||
val includePhone = binding.switchIncludePhone.isChecked
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||
val store = CredentialStore(ctx)
|
||||
val mobile = if (includePhone) {
|
||||
val account = selectedAccount
|
||||
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||
val store = CredentialStore(ctx)
|
||||
when (account.bank) {
|
||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||
@@ -144,17 +181,28 @@ class PayMvQrFragment : Fragment() {
|
||||
when {
|
||||
m.startsWith("+") -> m
|
||||
m.length == 7 -> "+960$m"
|
||||
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
|
||||
else -> m
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
val purpose = binding.etReference.text?.toString()?.trim()
|
||||
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
// The reference (62/05) is also printed vertically beside the QR, as each bank does
|
||||
val reference = when (target.bank) {
|
||||
// BML: base-32 account number followed by the amount as typed
|
||||
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
|
||||
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
|
||||
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
|
||||
else -> generateReference(9)
|
||||
}
|
||||
|
||||
val bmp = withContext(Dispatchers.Default) {
|
||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
||||
renderQrCard(ctx, account, payload, amountFormatted)
|
||||
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank)
|
||||
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
|
||||
else renderQrCard(ctx, target, payload, reference)
|
||||
}
|
||||
if (_binding == null) return
|
||||
generatedBitmap = bmp
|
||||
@@ -173,14 +221,19 @@ class PayMvQrFragment : Fragment() {
|
||||
acquirer: String,
|
||||
amountStr: String?,
|
||||
mobile: String?,
|
||||
purpose: String
|
||||
purpose: String?,
|
||||
ref: String,
|
||||
bank: 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")
|
||||
// Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount
|
||||
// as "***" when they don't; its scanner may reject QRs that differ
|
||||
val fahipay = bank == "FAHIPAY"
|
||||
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
|
||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||
val sub01 = tlv("01", acquirer)
|
||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||
@@ -190,21 +243,28 @@ class PayMvQrFragment : Fragment() {
|
||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||
val mcc = tlv("52", "0000")
|
||||
val currency = tlv("53", "462")
|
||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||
val amountTLV = when {
|
||||
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
|
||||
fahipay -> tlv("54", "***")
|
||||
else -> ""
|
||||
}
|
||||
val country = tlv("58", "MV")
|
||||
val name = tlv("59", accountName.take(25))
|
||||
val ref = generateReference()
|
||||
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
||||
val name = tlv("59", accountName.uppercase().take(25))
|
||||
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
|
||||
val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else ""
|
||||
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
|
||||
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
|
||||
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
|
||||
val timestamp = java.time.LocalDateTime.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
||||
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304"
|
||||
return prefix + crc16(prefix)
|
||||
}
|
||||
|
||||
private fun generateReference(): String {
|
||||
private fun generateReference(length: Int): String {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return (1..9).map { chars.random() }.joinToString("")
|
||||
return (1..length).map { chars.random() }.joinToString("")
|
||||
}
|
||||
|
||||
private fun crc16(data: String): String {
|
||||
@@ -221,88 +281,127 @@ class PayMvQrFragment : Fragment() {
|
||||
|
||||
// ── QR card rendering ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
|
||||
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
|
||||
* [PX_PER_DP] pixels per dp.
|
||||
*/
|
||||
private fun renderQrCard(
|
||||
ctx: Context,
|
||||
account: BankAccount,
|
||||
target: QrTarget,
|
||||
qrPayload: String,
|
||||
amountStr: String?
|
||||
qrId: 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 sw = SCREEN_WIDTH_DP
|
||||
fun px(dp: Float) = dp * PX_PER_DP
|
||||
val mmaBlue = Color.parseColor("#0E5CA4")
|
||||
|
||||
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||
val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side
|
||||
val qrSize = sw * 0.5f
|
||||
val railW = sw / 1.85f
|
||||
|
||||
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = px(14f)
|
||||
typeface = Typeface.DEFAULT
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textSize = px(sw * 0.046f)
|
||||
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
// Android RN Text lines include font padding: line height = bottom - top
|
||||
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
|
||||
|
||||
// --- Vertical layout (dp, card-local) ---
|
||||
val topCardTop = 2f
|
||||
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
|
||||
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
|
||||
val logoBoxH = logoBoxW * 0.1116751269035533f
|
||||
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
|
||||
val payMvBoxH = payMvBoxW * 0.17333333333333334f
|
||||
val brandH = maxOf(logoBoxH, payMvBoxH)
|
||||
val nameText = target.name.uppercase()
|
||||
val hasName = nameText.isNotBlank()
|
||||
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
|
||||
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
|
||||
val nameH = if (hasName) lineHeight(namePaint) else 0f
|
||||
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
|
||||
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
|
||||
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
|
||||
val footerLineH = lineHeight(footerPaint)
|
||||
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
|
||||
|
||||
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), 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
|
||||
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
|
||||
val outerPath = Path().apply {
|
||||
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW)
|
||||
}
|
||||
canvas.clipPath(outerPath)
|
||||
canvas.drawColor(mmaBlue)
|
||||
|
||||
// topCard: white, 2dp inset, top corners 18
|
||||
paint.color = Color.WHITE
|
||||
val r = px(18f)
|
||||
canvas.drawPath(Path().apply {
|
||||
addRoundRect(
|
||||
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)),
|
||||
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||
)
|
||||
}, paint)
|
||||
|
||||
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
|
||||
val rowL = 2f + 40f
|
||||
val rowR = cardW - 2f - 40f
|
||||
val rowCenterY = brandTop + brandH / 2
|
||||
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
|
||||
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)
|
||||
// resizeMode "contain" inside the logo box, left-aligned in the row
|
||||
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / 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)
|
||||
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
|
||||
val lTop = (px(rowCenterY) - lH / 2f).toInt()
|
||||
d.setBounds(lLeft, lTop, lLeft + 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)
|
||||
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
|
||||
paint.color = mmaBlue
|
||||
paint.typeface = footerPaint.typeface
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||
paint.textSize = px(payMvBoxH)
|
||||
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
|
||||
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
|
||||
canvas.drawText(
|
||||
"PayMV QR", px(rowR),
|
||||
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
|
||||
)
|
||||
|
||||
// --- Blue rounded box ---
|
||||
paint.color = boxBlue
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||
// --- qrCard: mmaBlue, radius 16, 40dp side margins ---
|
||||
val qrCardL = 2f + 40f
|
||||
val qrCardR = cardW - 2f - 40f
|
||||
paint.color = mmaBlue
|
||||
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), 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
|
||||
if (hasName) {
|
||||
val maxNameW = px(qrCardR - qrCardL)
|
||||
if (namePaint.measureText(nameText) > maxNameW) {
|
||||
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||
}
|
||||
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
|
||||
}
|
||||
|
||||
// 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()
|
||||
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
|
||||
val qrPx = px(qrSize).toInt()
|
||||
val qrLeft = px(cardW / 2) - qrPx / 2f
|
||||
try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
@@ -312,23 +411,150 @@ class PayMvQrFragment : Fragment() {
|
||||
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
|
||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
|
||||
}
|
||||
}
|
||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||
canvas.drawBitmap(qrBm, qrLeft, px(qrTop), 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)
|
||||
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
|
||||
if (qrId.isNotEmpty()) {
|
||||
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.BLACK
|
||||
alpha = (255 * 0.8f).toInt()
|
||||
textSize = px(10f)
|
||||
typeface = Typeface.DEFAULT
|
||||
textAlign = Paint.Align.CENTER
|
||||
}
|
||||
val qrRight = cardW / 2 + qrSize / 2
|
||||
val cx = px(qrRight + railW / 1.37f - railW / 2)
|
||||
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
|
||||
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
|
||||
canvas.save()
|
||||
canvas.rotate(-90f, cx, cy)
|
||||
val fm = idPaint.fontMetrics
|
||||
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
|
||||
canvas.drawText(
|
||||
"MALDIVES NATIONAL QR", px(cardW / 2),
|
||||
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
|
||||
)
|
||||
|
||||
return bm
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
|
||||
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
|
||||
* except the vertical reference, which is Montserrat as on Fahipay's.
|
||||
*/
|
||||
private fun renderFahipayQrCard(
|
||||
ctx: Context,
|
||||
target: QrTarget,
|
||||
qrPayload: String,
|
||||
reference: String
|
||||
): Bitmap {
|
||||
val w = 1240f
|
||||
val h = 1322f
|
||||
val blue = Color.parseColor("#005DA3")
|
||||
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
|
||||
|
||||
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// Blue card with a 6px border around the white area; footer is the blue below it
|
||||
canvas.clipPath(Path().apply {
|
||||
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
|
||||
})
|
||||
canvas.drawColor(blue)
|
||||
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)
|
||||
canvas.drawPath(Path().apply {
|
||||
addRoundRect(
|
||||
RectF(6f, 6f, w - 6f, 1174f),
|
||||
floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||
)
|
||||
}, paint)
|
||||
|
||||
// Square app icon, then the "FahiPay" wordmark, in one row
|
||||
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
|
||||
d.setBounds(94, 94, 163, 163)
|
||||
d.draw(canvas)
|
||||
}
|
||||
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
|
||||
val lH = 48
|
||||
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
|
||||
d.setBounds(178, 104, 178 + lW, 104 + lH)
|
||||
d.draw(canvas)
|
||||
}
|
||||
|
||||
// Sized so capitals match the reference's cap heights; positions below are cap tops
|
||||
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
|
||||
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
typeface = tf
|
||||
letterSpacing = spacingEm
|
||||
textAlign = align
|
||||
textSize = 100f
|
||||
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
|
||||
textSize = 100f * capH / h
|
||||
}
|
||||
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
|
||||
|
||||
// "PayMV QR" top-right
|
||||
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
|
||||
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
|
||||
|
||||
// Blue QR panel
|
||||
paint.color = blue
|
||||
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
|
||||
|
||||
// Account name
|
||||
val nameText = target.name.uppercase()
|
||||
if (nameText.isNotBlank()) {
|
||||
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||
val maxNameW = 1074f - 166f - 80f
|
||||
if (namePaint.measureText(nameText) > maxNameW) {
|
||||
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||
}
|
||||
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint)
|
||||
}
|
||||
|
||||
// QR — white modules on blue, no quiet zone
|
||||
val qrPx = 562
|
||||
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 blue
|
||||
}
|
||||
}
|
||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||
canvas.drawBitmap(qrBm, 338f, 417f, null)
|
||||
qrBm.recycle()
|
||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||
|
||||
// Reference, blue, reading bottom-to-top in the white margin right of the panel,
|
||||
// starting level with y=1087 and with its baseline at x=1171
|
||||
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue }
|
||||
canvas.save()
|
||||
canvas.rotate(-90f, 1171f, 1087f)
|
||||
canvas.drawText(reference, 1171f, 1087f, refPaint)
|
||||
canvas.restore()
|
||||
|
||||
// Footer
|
||||
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
|
||||
|
||||
return bm
|
||||
}
|
||||
@@ -337,13 +563,13 @@ class PayMvQrFragment : Fragment() {
|
||||
|
||||
private fun shareQr() {
|
||||
val bmp = generatedBitmap ?: return
|
||||
val account = selectedAccount ?: return
|
||||
val target = currentTarget() ?: 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 safeName = target.name.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(
|
||||
@@ -365,11 +591,11 @@ class PayMvQrFragment : Fragment() {
|
||||
|
||||
private fun saveQr() {
|
||||
val bmp = generatedBitmap ?: return
|
||||
val account = selectedAccount ?: return
|
||||
val target = currentTarget() ?: return
|
||||
lifecycleScope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||
val safeName = target.name.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 {
|
||||
@@ -411,6 +637,25 @@ class PayMvQrFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
private const val ARG_ACCOUNT_NAME = "account_name"
|
||||
private const val ARG_BANK = "bank"
|
||||
|
||||
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
|
||||
private const val SCREEN_WIDTH_DP = 560f
|
||||
private const val PX_PER_DP = 2f
|
||||
|
||||
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
|
||||
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_NUMBER, accountNumber)
|
||||
putString(ARG_ACCOUNT_NAME, name)
|
||||
putString(ARG_BANK, bank)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||
|
||||
private inner class QrAccountAdapter(
|
||||
|
||||
@@ -45,15 +45,18 @@ import sh.sar.basedbank.api.bml.BmlCardClient
|
||||
import sh.sar.basedbank.api.bml.BmlTapToPayClient
|
||||
import sh.sar.basedbank.api.mib.MibCardsClient
|
||||
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||
import sh.sar.basedbank.nfc.EmvCardReader
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import android.text.InputType
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import sh.sar.basedbank.databinding.DialogCardManualVerifyBinding
|
||||
import sh.sar.basedbank.databinding.FragmentCardsBinding
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.util.VerifiedCardStore
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
@@ -77,10 +80,10 @@ class CardsFragment : Fragment() {
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
@@ -123,15 +126,11 @@ class CardsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val screenW = resources.displayMetrics.widthPixels
|
||||
val peekPx = screenW / 8
|
||||
cardWidth = screenW - 2 * peekPx
|
||||
|
||||
stackAdapter = CardStackAdapter(cardWidth)
|
||||
stackAdapter = CardStackAdapter()
|
||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.rvCards.adapter = stackAdapter
|
||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
||||
binding.rvCards.clipToPadding = false
|
||||
applyCarouselWidth()
|
||||
|
||||
val snapHelper = PagerSnapHelper()
|
||||
snapHelper.attachToRecyclerView(binding.rvCards)
|
||||
@@ -155,12 +154,11 @@ class CardsFragment : Fragment() {
|
||||
}
|
||||
})
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { 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(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + extraBottom)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { 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.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + navBarBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
@@ -270,6 +268,253 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
}
|
||||
binding.btnBlock.setOnClickListener(wip)
|
||||
binding.btnVerify.setOnClickListener {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
||||
// Already-verified cards: a tap only informs; long-press re-verifies to update.
|
||||
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) {
|
||||
Toast.makeText(requireContext(), R.string.card_verify_already, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
onVerifyClicked(item)
|
||||
}
|
||||
}
|
||||
binding.btnVerify.setOnLongClickListener {
|
||||
cards.getOrNull(currentCardPosition)?.let { onVerifyClicked(it) }
|
||||
true
|
||||
}
|
||||
binding.btnCancelVerify.setOnClickListener { setVerifyMode(false) }
|
||||
binding.btnManualVerify.setOnClickListener {
|
||||
verifyItem?.let { showCardDetailsDialog(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Card verification (NFC tap or manual entry) ───────────────────────────
|
||||
|
||||
private var isVerifyMode = false
|
||||
private var verifyItem: CardItem? = null
|
||||
private var verifyAnimView: CardVerifyAnimationView? = null
|
||||
/** True while the CVV / manual dialog is up; the NFC reader stays off meanwhile. */
|
||||
private var verifyDialogOpen = false
|
||||
|
||||
private fun cardLast4(item: CardItem): String {
|
||||
val number = when (item) {
|
||||
is CardItem.Bml -> item.account.accountNumber
|
||||
is CardItem.Mib -> item.card.maskedCardNumber
|
||||
}
|
||||
return number.filter { it.isDigit() }.takeLast(4)
|
||||
}
|
||||
|
||||
private fun onVerifyClicked(item: CardItem) {
|
||||
val ctx = requireContext()
|
||||
val adapter = android.nfc.NfcAdapter.getDefaultAdapter(ctx)
|
||||
when {
|
||||
adapter == null -> showCardDetailsDialog(item)
|
||||
!adapter.isEnabled -> MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.nfc_disabled_title)
|
||||
.setMessage(R.string.card_verify_nfc_disabled_message)
|
||||
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
|
||||
startActivity(Intent(android.provider.Settings.ACTION_NFC_SETTINGS))
|
||||
}
|
||||
.setNeutralButton(R.string.card_verify_manual) { _, _ -> showCardDetailsDialog(item) }
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
else -> setVerifyMode(true, item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setVerifyMode(enabled: Boolean, item: CardItem? = null) {
|
||||
if (enabled == isVerifyMode) return
|
||||
isVerifyMode = enabled
|
||||
verifyItem = if (enabled) item else null
|
||||
verifyDialogOpen = false
|
||||
requireActivity().title = getString(if (enabled) R.string.card_verify_title else R.string.card_manage)
|
||||
|
||||
val manageVisibility = if (enabled) View.GONE else View.VISIBLE
|
||||
binding.llManageButtons.visibility = manageVisibility
|
||||
binding.llDefaultCardRow.visibility = manageVisibility
|
||||
binding.llHideDashboardRow.visibility = manageVisibility
|
||||
binding.bottomSpacer.visibility = manageVisibility
|
||||
binding.flVerifyArea.visibility = if (enabled) View.VISIBLE else View.GONE
|
||||
binding.llVerifyButtons.visibility = if (enabled) View.VISIBLE else View.GONE
|
||||
|
||||
binding.flVerifyArea.removeAllViews()
|
||||
if (enabled) {
|
||||
val anim = CardVerifyAnimationView(requireContext()).apply {
|
||||
waitingLabel = getString(R.string.card_verify_tap)
|
||||
alpha = 0f
|
||||
}
|
||||
verifyAnimView = anim
|
||||
binding.flVerifyArea.addView(anim, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
anim.animate().alpha(1f).setDuration(300).start()
|
||||
startVerifyReader()
|
||||
} else {
|
||||
verifyAnimView = null
|
||||
stopVerifyReader()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startVerifyReader() {
|
||||
if (!isVerifyMode || verifyDialogOpen || !isResumed) return
|
||||
val activity = requireActivity()
|
||||
val adapter = android.nfc.NfcAdapter.getDefaultAdapter(activity) ?: return
|
||||
adapter.enableReaderMode(activity, { tag ->
|
||||
// Binder thread: fine to block on the card here.
|
||||
view?.post {
|
||||
if (isVerifyMode) verifyAnimView?.setState(
|
||||
CardVerifyAnimationView.State.READING, getString(R.string.card_verify_reading))
|
||||
}
|
||||
val data = runCatching { EmvCardReader.read(tag) }.getOrNull()
|
||||
view?.post { onVerifyCardRead(data) }
|
||||
}, android.nfc.NfcAdapter.FLAG_READER_NFC_A or android.nfc.NfcAdapter.FLAG_READER_NFC_B or
|
||||
android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null)
|
||||
}
|
||||
|
||||
private fun stopVerifyReader() {
|
||||
val activity = activity ?: return
|
||||
android.nfc.NfcAdapter.getDefaultAdapter(activity)?.disableReaderMode(activity)
|
||||
}
|
||||
|
||||
private fun onVerifyCardRead(data: EmvCardReader.CardData?) {
|
||||
val item = verifyItem
|
||||
if (!isVerifyMode || item == null || _binding == null || verifyDialogOpen) return
|
||||
val anim = verifyAnimView
|
||||
val expected = cardLast4(item)
|
||||
when {
|
||||
data == null -> anim?.setState(CardVerifyAnimationView.State.ERROR,
|
||||
getString(R.string.card_verify_read_failed))
|
||||
data.pan.takeLast(4) != expected -> anim?.setState(CardVerifyAnimationView.State.ERROR,
|
||||
getString(R.string.card_verify_mismatch, data.pan.takeLast(4)))
|
||||
else -> {
|
||||
anim?.setState(CardVerifyAnimationView.State.SUCCESS, getString(R.string.card_verify_matched))
|
||||
verifyDialogOpen = true
|
||||
stopVerifyReader()
|
||||
// Let the check mark land before the dialog covers it
|
||||
binding.root.postDelayed({
|
||||
if (isVerifyMode && verifyItem === item && _binding != null) showCardDetailsDialog(item, data)
|
||||
}, 750)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeWaitingForTap() {
|
||||
verifyDialogOpen = false
|
||||
if (!isVerifyMode) return
|
||||
verifyAnimView?.setState(CardVerifyAnimationView.State.WAITING)
|
||||
startVerifyReader()
|
||||
}
|
||||
|
||||
private fun cardHolderName(item: CardItem): String = when (item) {
|
||||
is CardItem.Bml -> item.account.accountBriefName
|
||||
is CardItem.Mib -> item.card.cardHolderName
|
||||
}
|
||||
|
||||
/**
|
||||
* Card details form. With [nfcData] (after a matching tap) the number and expiry read from the
|
||||
* chip are prefilled and locked, so only the CVV is asked for; without it everything but the
|
||||
* name is entered manually. The name always comes from the bank API and is read-only.
|
||||
*/
|
||||
private fun showCardDetailsDialog(item: CardItem, nfcData: EmvCardReader.CardData? = null) {
|
||||
val ctx = requireContext()
|
||||
val expected = cardLast4(item)
|
||||
val b = DialogCardManualVerifyBinding.inflate(layoutInflater)
|
||||
|
||||
b.etName.setText(cardHolderName(item))
|
||||
b.tilName.isEnabled = false
|
||||
|
||||
// Auto-insert the "/" in MM/YY while typing forwards
|
||||
b.etExpiry.addTextChangedListener(object : android.text.TextWatcher {
|
||||
private var deleting = false
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { deleting = after < count }
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
override fun afterTextChanged(s: android.text.Editable) {
|
||||
if (!deleting && s.length == 2 && !s.contains('/')) s.append('/')
|
||||
}
|
||||
})
|
||||
|
||||
if (nfcData != null) {
|
||||
b.etCardNumber.setText(nfcData.pan.chunked(4).joinToString(" "))
|
||||
b.tilCardNumber.isEnabled = false
|
||||
nfcData.expiry?.let {
|
||||
b.etExpiry.setText(it)
|
||||
b.tilExpiry.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
if (isVerifyMode) {
|
||||
verifyDialogOpen = true
|
||||
stopVerifyReader()
|
||||
}
|
||||
var saved = false
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(if (nfcData != null) getString(R.string.card_verify_cvv_title, nfcData.pan.takeLast(4))
|
||||
else getString(R.string.card_verify_manual_title))
|
||||
.setView(b.root)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.card_verify_confirm, null)
|
||||
.setOnDismissListener { if (!saved && isVerifyMode) resumeWaitingForTap() }
|
||||
.create()
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener {
|
||||
b.tilCardNumber.error = null; b.tilExpiry.error = null; b.tilCvv.error = null
|
||||
val pan = b.etCardNumber.text?.toString().orEmpty().filter { it.isDigit() }
|
||||
val expiry = normalizeExpiry(b.etExpiry.text?.toString().orEmpty())
|
||||
val cvv = b.etCvv.text?.toString().orEmpty()
|
||||
var ok = true
|
||||
if (pan.length !in 12..19 || !luhnValid(pan)) {
|
||||
b.tilCardNumber.error = getString(R.string.card_verify_number_invalid); ok = false
|
||||
} else if (pan.takeLast(4) != expected) {
|
||||
b.tilCardNumber.error = getString(R.string.card_verify_number_mismatch, expected); ok = false
|
||||
}
|
||||
if (expiry == null) { b.tilExpiry.error = getString(R.string.card_verify_expiry_invalid); ok = false }
|
||||
if (!cvv.matches(Regex("\\d{3,4}"))) { b.tilCvv.error = getString(R.string.card_verify_cvv_invalid); ok = false }
|
||||
if (!ok) return@setOnClickListener
|
||||
|
||||
saved = true
|
||||
saveVerifiedCard(item, VerifiedCardStore.VerifiedCard(
|
||||
pan = pan,
|
||||
expiry = expiry!!,
|
||||
cvv = cvv,
|
||||
method = if (nfcData != null) VerifiedCardStore.METHOD_NFC else VerifiedCardStore.METHOD_MANUAL,
|
||||
verifiedAt = System.currentTimeMillis()
|
||||
))
|
||||
dialog.dismiss()
|
||||
}
|
||||
// Focus the first field the user actually has to fill in
|
||||
val firstEditable = listOf(b.tilCardNumber to b.etCardNumber, b.tilExpiry to b.etExpiry, b.tilCvv to b.etCvv)
|
||||
.first { it.first.isEnabled }.second
|
||||
firstEditable.requestFocus()
|
||||
}
|
||||
dialog.window?.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun saveVerifiedCard(item: CardItem, card: VerifiedCardStore.VerifiedCard) {
|
||||
VerifiedCardStore.save(requireContext(), cardItemKey(item), card)
|
||||
Toast.makeText(requireContext(), R.string.card_verify_success, Toast.LENGTH_SHORT).show()
|
||||
setVerifyMode(false)
|
||||
if (isManageMode) cards.getOrNull(currentCardPosition)?.let { bindManageCardData(it) }
|
||||
}
|
||||
|
||||
/** Accepts "MMYY" or "MM/YY"; returns "MM/YY" if it's a valid, unexpired month. */
|
||||
private fun normalizeExpiry(raw: String): String? {
|
||||
val m = Regex("^(0[1-9]|1[0-2])/?(\\d{2})$").find(raw.trim()) ?: return null
|
||||
val month = m.groupValues[1].toInt()
|
||||
val year = 2000 + m.groupValues[2].toInt()
|
||||
val now = java.util.Calendar.getInstance()
|
||||
val nowYear = now.get(java.util.Calendar.YEAR)
|
||||
val nowMonth = now.get(java.util.Calendar.MONTH) + 1
|
||||
if (year < nowYear || (year == nowYear && month < nowMonth)) return null
|
||||
return "%02d/%02d".format(month, year % 100)
|
||||
}
|
||||
|
||||
private fun luhnValid(pan: String): Boolean {
|
||||
var sum = 0
|
||||
pan.reversed().forEachIndexed { i, c ->
|
||||
var d = c - '0'
|
||||
if (i % 2 == 1) { d *= 2; if (d > 9) d -= 9 }
|
||||
sum += d
|
||||
}
|
||||
return sum % 10 == 0
|
||||
}
|
||||
|
||||
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
|
||||
@@ -405,6 +650,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
|
||||
private fun setManageMode(enabled: Boolean) {
|
||||
if (!enabled) setVerifyMode(false)
|
||||
isManageMode = enabled
|
||||
if (!enabled) managedCardKey = null
|
||||
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
||||
@@ -446,6 +692,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
|
||||
binding.btnChangePin.isEnabled = !mibFrozen
|
||||
binding.btnBlock.isEnabled = !mibFrozen
|
||||
binding.btnVerify.setText(
|
||||
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) R.string.card_action_verified
|
||||
else R.string.card_action_verify
|
||||
)
|
||||
}
|
||||
|
||||
private fun rebindManagedCardIfNeeded() {
|
||||
@@ -638,8 +888,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
||||
|
||||
/** Held while tap mode is up: recreating the activity would clear the NFC payment token. */
|
||||
private var tapGuard: HomeActivity.PaymentGuard? = null
|
||||
|
||||
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||
isTapMode = enabled
|
||||
tapGuard?.end()
|
||||
tapGuard = if (enabled) (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) else null
|
||||
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||
}
|
||||
@@ -905,7 +1160,9 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
||||
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
|
||||
val rank = store.loginRank()
|
||||
val all: List<CardItem> = (bmlActive + mibActive).sortedBy { rank(it.loginTag) } +
|
||||
(bmlInactive + mibInactive).sortedBy { rank(it.loginTag) }
|
||||
// Move default BML card to front
|
||||
cards = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
@@ -958,6 +1215,25 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
}
|
||||
|
||||
/** Sizes the carousel from the window width: each card leaves a 1/8 peek on either side. */
|
||||
private fun applyCarouselWidth() {
|
||||
val screenW = resources.displayMetrics.widthPixels
|
||||
val peekPx = screenW / 8
|
||||
cardWidth = screenW - 2 * peekPx
|
||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
||||
}
|
||||
|
||||
// HomeActivity handles size changes itself (rotation, split screen) rather than being
|
||||
// recreated, so the carousel has to re-measure for the new width on its own.
|
||||
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
if (_binding == null) return
|
||||
applyCarouselWidth()
|
||||
stackAdapter.notifyDataSetChanged()
|
||||
binding.rvCards.scrollToPosition(currentCardPosition)
|
||||
binding.rvCards.post { if (_binding != null) applyCardScales() }
|
||||
}
|
||||
|
||||
private fun applyCardScales() {
|
||||
val rv = binding.rvCards
|
||||
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
||||
@@ -1017,6 +1293,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
if (isVerifyMode) {
|
||||
setVerifyMode(false)
|
||||
return true
|
||||
}
|
||||
if (isTapMode) {
|
||||
setTapMode(false)
|
||||
return true
|
||||
@@ -1030,6 +1310,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (isVerifyMode) stopVerifyReader()
|
||||
if (isTapMode) {
|
||||
BmlHostCardEmulatorService.clearToken()
|
||||
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||
@@ -1038,10 +1319,17 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_pay_with_card)
|
||||
if (isVerifyMode) startVerifyReader()
|
||||
requireActivity().title = getString(when {
|
||||
isVerifyMode -> R.string.card_verify_title
|
||||
isTapMode -> R.string.card_pay_nfc
|
||||
isManageMode -> R.string.card_manage
|
||||
else -> R.string.nav_pay_with_card
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
if (isVerifyMode) stopVerifyReader()
|
||||
tapAnimView?.stopAnimation()
|
||||
tapAnimView = null
|
||||
BmlHostCardEmulatorService.clearToken()
|
||||
@@ -1050,7 +1338,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
||||
private inner class CardStackAdapter : RecyclerView.Adapter<CardStackAdapter.VH>() {
|
||||
private var items: List<CardItem> = emptyList()
|
||||
|
||||
fun update(newItems: List<CardItem>) {
|
||||
@@ -1065,6 +1353,8 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.bind(items[position])
|
||||
// Re-applied on every bind so a width change reaches recycled holders too
|
||||
holder.itemView.layoutParams.width = cardWidth
|
||||
// Pre-scale based on data position so initial render and off-screen cards are correct
|
||||
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
||||
val scale = 1f - 0.18f * fraction
|
||||
|
||||
@@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
val isDark = prefs.getString("theme", "system") == "dark"
|
||||
updatePitchBlackState(isDark)
|
||||
|
||||
// Receipts
|
||||
binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false)
|
||||
binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply()
|
||||
}
|
||||
|
||||
// Accent color
|
||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||
binding.accentToggle.check(when (savedPreset) {
|
||||
|
||||
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.Gravity
|
||||
import android.view.HapticFeedbackConstants
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
@@ -30,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlProfile
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
@@ -359,56 +363,86 @@ class SettingsLoginsFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||
|
||||
private fun buildLoginsSection() {
|
||||
val ctx = requireContext()
|
||||
val store = CredentialStore(ctx)
|
||||
val container = binding.loginsContainer
|
||||
container.removeAllViews()
|
||||
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
val entries = mutableListOf<LoginEntry>()
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
for (loginId in mibLoginIds) {
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val profile = store.loadMibUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||
val mibProfiles = store.loadMibProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
|
||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in bmlLoginIds) {
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
val profile = store.loadBmlUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
|
||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in fahipayLoginIds) {
|
||||
for (loginId in store.getFahipayLoginIds()) {
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in store.getMfaisaLoginIds()) {
|
||||
val profile = store.loadMfaisaUserProfile(loginId)
|
||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
|
||||
showMfaisaLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
val rank = store.loginRank()
|
||||
val sorted = entries.sortedBy { rank(it.key) }
|
||||
val draggable = sorted.size > 1
|
||||
for (entry in sorted) {
|
||||
addLoginRow(
|
||||
container, entry.logoRes, entry.displayName, entry.onClick,
|
||||
dragTag = entry.key.takeIf { draggable },
|
||||
onReordered = { order ->
|
||||
store.setLoginOrder(order)
|
||||
viewModel.resortByLoginOrder()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||
private fun addLoginRow(
|
||||
container: LinearLayout,
|
||||
logoRes: Int,
|
||||
displayName: String,
|
||||
onClick: () -> Unit,
|
||||
dragTag: String? = null,
|
||||
onReordered: (List<String>) -> Unit = {}
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
||||
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
|
||||
isClickable = true; isFocusable = true
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
setOnClickListener { onClick() }
|
||||
tag = dragTag
|
||||
}
|
||||
val logo = ImageView(ctx).apply {
|
||||
setImageResource(logoRes)
|
||||
@@ -421,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
row.addView(logo); row.addView(tvName)
|
||||
if (dragTag != null) {
|
||||
val handle = ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_reorder_lines)
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
contentDescription = getString(R.string.drag_to_reorder)
|
||||
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
|
||||
}
|
||||
row.addView(handle)
|
||||
attachDragHandle(handle, row, container, onReordered)
|
||||
}
|
||||
container.addView(row)
|
||||
}
|
||||
|
||||
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
|
||||
val ctx = handle.context
|
||||
val lift = 8 * ctx.resources.displayMetrics.density
|
||||
val dragBg = android.graphics.drawable.ColorDrawable(
|
||||
com.google.android.material.color.MaterialColors.getColor(
|
||||
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
|
||||
var lastY = 0f
|
||||
var startIndex = 0
|
||||
var savedBg: android.graphics.drawable.Drawable? = null
|
||||
|
||||
handle.setOnTouchListener { v, ev ->
|
||||
when (ev.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
v.parent.requestDisallowInterceptTouchEvent(true)
|
||||
lastY = ev.rawY
|
||||
startIndex = group.indexOfChild(row)
|
||||
row.animate().cancel()
|
||||
savedBg = row.background
|
||||
row.background = dragBg
|
||||
row.translationZ = lift
|
||||
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
row.translationY += ev.rawY - lastY
|
||||
lastY = ev.rawY
|
||||
var index = group.indexOfChild(row)
|
||||
while (index < group.childCount - 1) {
|
||||
val next = group.getChildAt(index + 1)
|
||||
if (row.translationY <= next.height / 2f) break
|
||||
group.removeView(next); group.addView(next, index)
|
||||
row.translationY -= next.height
|
||||
next.translationY = row.height.toFloat()
|
||||
next.animate().translationY(0f).setDuration(150).start()
|
||||
index++
|
||||
}
|
||||
while (index > 0) {
|
||||
val prev = group.getChildAt(index - 1)
|
||||
if (row.translationY >= -prev.height / 2f) break
|
||||
group.removeView(prev); group.addView(prev, index)
|
||||
row.translationY += prev.height
|
||||
prev.translationY = -row.height.toFloat()
|
||||
prev.animate().translationY(0f).setDuration(150).start()
|
||||
index--
|
||||
}
|
||||
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
|
||||
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
|
||||
}
|
||||
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||
v.parent.requestDisallowInterceptTouchEvent(false)
|
||||
row.animate().translationY(0f).translationZ(0f).setDuration(150)
|
||||
.withEndAction { row.background = savedBg }
|
||||
.start()
|
||||
if (group.indexOfChild(row) != startIndex) {
|
||||
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
|
||||
private fun addAccountRow(
|
||||
ctx: Context,
|
||||
container: LinearLayout,
|
||||
dp: Float,
|
||||
acc: BankAccount,
|
||||
hiddenAccounts: MutableSet<String>,
|
||||
indent: Boolean
|
||||
): Pair<BankAccount, MaterialSwitch> {
|
||||
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()
|
||||
if (indent) it.marginStart = (28 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
|
||||
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
|
||||
setTextAppearance(nameAppearance)
|
||||
})
|
||||
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
|
||||
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||
else acc.accountTypeName.trim()
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
|
||||
.joinToString(" · ")
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.6f
|
||||
})
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = acc.accountNumber !in hiddenAccounts
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
row.addView(textCol)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
return acc to toggle
|
||||
}
|
||||
|
||||
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
|
||||
private fun addNestedAccountRows(
|
||||
ctx: Context,
|
||||
container: LinearLayout,
|
||||
dp: Float,
|
||||
accounts: List<BankAccount>,
|
||||
hiddenAccounts: MutableSet<String>
|
||||
): List<Pair<BankAccount, MaterialSwitch>> =
|
||||
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
|
||||
|
||||
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
|
||||
private fun addAccountsSection(
|
||||
ctx: Context,
|
||||
container: LinearLayout,
|
||||
dp: Float,
|
||||
accounts: List<BankAccount>,
|
||||
hiddenAccounts: MutableSet<String>,
|
||||
showDivider: Boolean
|
||||
): List<Pair<BankAccount, MaterialSwitch>> {
|
||||
if (accounts.isEmpty()) return emptyList()
|
||||
if (showDivider) {
|
||||
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.accounts)
|
||||
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()
|
||||
}
|
||||
})
|
||||
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
|
||||
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
|
||||
* the account listeners ignore changes made while a toggle is disabled.
|
||||
*/
|
||||
private fun syncAccountToggles(
|
||||
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
|
||||
hiddenAccounts: Set<String>,
|
||||
isParentHidden: (BankAccount) -> Boolean
|
||||
) {
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
if (isParentHidden(acc)) {
|
||||
toggle.isEnabled = false
|
||||
toggle.isChecked = false
|
||||
} else {
|
||||
toggle.isChecked = acc.accountNumber !in hiddenAccounts
|
||||
toggle.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
|
||||
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
|
||||
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
|
||||
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
|
||||
}
|
||||
|
||||
private fun showMibLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
@@ -434,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||
val hidden = originalHidden.toMutableSet()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
|
||||
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
@@ -476,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
})
|
||||
}
|
||||
|
||||
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
|
||||
// Build toggle rows — wired up after dialog.show() so we can reference the Save button.
|
||||
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||
// can have multiple accounts.
|
||||
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||
val toggleRows = mibProfiles.map { p ->
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
@@ -517,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||
p to toggle
|
||||
}
|
||||
|
||||
// Accounts that don't belong to any known profile still need to be reachable.
|
||||
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
|
||||
accountRows += addAccountsSection(
|
||||
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||
showDivider = mibProfiles.isNotEmpty() || profile != null
|
||||
)
|
||||
|
||||
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
|
||||
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
@@ -550,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenMibProfileIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||
val home = activity as? HomeActivity
|
||||
home?.applyVisibility()
|
||||
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||
}
|
||||
val originalHidden = hidden.toSet()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
|
||||
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
@@ -621,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
|
||||
})
|
||||
}
|
||||
|
||||
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||
// can have multiple accounts.
|
||||
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||
val toggleRows = bmlProfiles.map { p ->
|
||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
@@ -668,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||
p to toggle
|
||||
}
|
||||
|
||||
// Accounts that don't belong to any known profile still need to be reachable.
|
||||
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
|
||||
accountRows += addAccountsSection(
|
||||
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||
showDivider = bmlProfiles.isNotEmpty() || profile != null
|
||||
)
|
||||
|
||||
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
|
||||
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
@@ -711,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||
val home = activity as? HomeActivity
|
||||
home?.applyVisibility()
|
||||
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
|
||||
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
@@ -988,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
|
||||
})
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.fahipay_name))
|
||||
.setView(scroll)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
.apply {
|
||||
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||
setNeutralButton(R.string.close, null)
|
||||
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
if (loginAccounts.isNotEmpty()) {
|
||||
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
saveBtn.isEnabled = false
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.applyVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||
@@ -1065,6 +1366,168 @@ class SettingsLoginsFragment : Fragment() {
|
||||
buildLoginsSection()
|
||||
}
|
||||
|
||||
private fun showMfaisaLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: CredentialStore.MfaisaUserProfile?
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
|
||||
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||
val originalHidden = hidden.toSet()
|
||||
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||
|
||||
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||
data class MfaisaProfileRow(val label: String, val pocketIds: Set<String>)
|
||||
val mfaisaPockets = pockets.filter { it.profileType != "MFAISA_PAYPAL" }
|
||||
val paypalPockets = pockets.filter { it.profileType == "MFAISA_PAYPAL" }
|
||||
val profileRows = buildList {
|
||||
if (mfaisaPockets.isNotEmpty()) {
|
||||
add(MfaisaProfileRow("M-Faisa", mfaisaPockets.map { it.accountNumber }.toSet()))
|
||||
}
|
||||
if (paypalPockets.isNotEmpty()) {
|
||||
add(MfaisaProfileRow("PayPal", paypalPockets.map { it.accountNumber }.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)
|
||||
|
||||
listOfNotNull(
|
||||
profile?.name?.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?.mdnId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (profileRows.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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Each group's own pockets nest directly beneath it (tree view), since a group
|
||||
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
|
||||
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||
val toggleRows = profileRows.map { row ->
|
||||
val v = 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 label = TextView(ctx).apply {
|
||||
text = row.label
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = row.pocketIds.any { it !in hidden }
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
v.addView(label)
|
||||
v.addView(toggle)
|
||||
container.addView(v)
|
||||
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
|
||||
row to toggle
|
||||
}
|
||||
|
||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||
val visibleCount = toggleRows.count { (row, _) -> row.pocketIds.any { it !in hidden } }
|
||||
toggleRows.forEach { (_, toggle) ->
|
||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||
}
|
||||
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
|
||||
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.ooredoo_name))
|
||||
.setView(scroll)
|
||||
.apply {
|
||||
if (profileRows.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||
setNeutralButton(R.string.close, null)
|
||||
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.ooredoo_name)) { logoutMfaisa(store, loginId) }
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
if (profileRows.isNotEmpty()) {
|
||||
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
saveBtn.isEnabled = false
|
||||
updateToggleStates(saveBtn)
|
||||
|
||||
toggleRows.forEach { (row, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) hidden.removeAll(row.pocketIds) else hidden.addAll(row.pocketIds)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
accountRows.forEach { (acc, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
|
||||
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
// All pockets come back in one login response, so hiding/unhiding never needs a request
|
||||
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||
persistHiddenAccounts(store, pockets, hiddenAccounts)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.applyVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logoutMfaisa(store: CredentialStore, loginId: String) {
|
||||
val ctx = requireContext()
|
||||
store.clearMfaisaCredentials(loginId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.mfaisaSessions.remove(loginId)
|
||||
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||
app.accounts = app.accounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
}
|
||||
|
||||
private fun clearAllCaches(ctx: Context) {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
@@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() {
|
||||
var bmlTotalPages: Int = -1,
|
||||
var cardMonthOffset: Int = 0,
|
||||
var fahipayNextStart: Int = 0,
|
||||
var fahipayTotal: Int = -1
|
||||
var fahipayTotal: Int = -1,
|
||||
var mfaisaNextPage: Int = 1,
|
||||
var mfaisaHasMore: Boolean = true
|
||||
) {
|
||||
// PayPal pockets have no known history endpoint, so they don't paginate here.
|
||||
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||
|
||||
fun hasMore(): Boolean = when {
|
||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
}
|
||||
}
|
||||
@@ -209,13 +216,14 @@ class TransferHistoryFragment : Fragment() {
|
||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
state.cardMonthOffset++
|
||||
BmlHistoryClient().fetchCardHistory(
|
||||
val cardResult = BmlHistoryClient().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
accountNumber = state.account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
cardResult.statement + cardResult.outstanding + cardResult.unbilled
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||
@@ -235,6 +243,36 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
// M-Faisa accounts (PayPal pockets are skipped by hasMore())
|
||||
val mfaisaStates = activeStates.filter { it.account.bank == "MFAISA" }
|
||||
for (state in mfaisaStates) {
|
||||
var session = app.mfaisaSessionFor(state.account) ?: continue
|
||||
try {
|
||||
val page = try {
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
pageNo = state.mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
val loginId = state.account.loginTag.removePrefix("mfaisa_")
|
||||
session = app.refreshMfaisaSession(loginId) ?: continue
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
pageNo = state.mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
}
|
||||
state.mfaisaHasMore = page.hasMore
|
||||
state.mfaisaNextPage++
|
||||
results.addAll(page.transactions)
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
}
|
||||
|
||||
// Fahipay accounts
|
||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||
for (state in fahipayStates) {
|
||||
|
||||
@@ -14,9 +14,17 @@ data class TransferReceiptData(
|
||||
// MIB receipt fields
|
||||
val mibReferenceNo: String = "",
|
||||
val mibTransactionDate: String = "",
|
||||
val mibFromProfileName: String = "",
|
||||
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
|
||||
// BML receipt fields
|
||||
val bmlFromName: String = "",
|
||||
val bmlReference: String = "",
|
||||
val bmlTimestamp: String = "",
|
||||
val bmlMessage: String = "",
|
||||
// M-Faisa receipt fields
|
||||
val mfaisaTransactionType: String = "",
|
||||
val mfaisaFromName: String = "",
|
||||
val mfaisaFromMsisdn: String = "",
|
||||
val mfaisaToMsisdn: String = "",
|
||||
val mfaisaTimestamp: Long = 0L,
|
||||
)
|
||||
|
||||
@@ -11,23 +11,20 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.PixelCopy
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
@@ -37,7 +34,10 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.DialogReceiptFullscreenBmlBinding
|
||||
import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -64,10 +64,17 @@ class TransferReceiptFragment : Fragment() {
|
||||
private const val ARG_REMARKS = "remarks"
|
||||
private const val ARG_MIB_REF = "mib_ref"
|
||||
private const val ARG_MIB_DATE = "mib_date"
|
||||
private const val ARG_MIB_FROM_PROFILE = "mib_from_profile"
|
||||
private const val ARG_MIB_TXN_TYPE = "mib_txn_type"
|
||||
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||
private const val ARG_BML_MESSAGE = "bml_message"
|
||||
private const val ARG_MFAISA_TXN_TYPE = "mfaisa_txn_type"
|
||||
private const val ARG_MFAISA_FROM_NAME = "mfaisa_from_name"
|
||||
private const val ARG_MFAISA_FROM_MSISDN = "mfaisa_from_msisdn"
|
||||
private const val ARG_MFAISA_TO_MSISDN = "mfaisa_to_msisdn"
|
||||
private const val ARG_MFAISA_TIMESTAMP = "mfaisa_timestamp"
|
||||
|
||||
// Holds the already-rendered to-avatar bitmap from TransferFragment
|
||||
var pendingToAvatarBitmap: Bitmap? = null
|
||||
@@ -87,26 +94,42 @@ class TransferReceiptFragment : Fragment() {
|
||||
putString(ARG_REMARKS, data.remarks)
|
||||
putString(ARG_MIB_REF, data.mibReferenceNo)
|
||||
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
||||
putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName)
|
||||
putString(ARG_MIB_TXN_TYPE, data.mibTransactionType)
|
||||
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||
putString(ARG_BML_MESSAGE, data.bmlMessage)
|
||||
putString(ARG_MFAISA_TXN_TYPE, data.mfaisaTransactionType)
|
||||
putString(ARG_MFAISA_FROM_NAME, data.mfaisaFromName)
|
||||
putString(ARG_MFAISA_FROM_MSISDN, data.mfaisaFromMsisdn)
|
||||
putString(ARG_MFAISA_TO_MSISDN, data.mfaisaToMsisdn)
|
||||
putLong(ARG_MFAISA_TIMESTAMP, data.mfaisaTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
return if (bank == "MIB") {
|
||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||
bindMib(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
} else {
|
||||
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
||||
bindBml(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
return when (bank) {
|
||||
"MIB" -> {
|
||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||
bindMib(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
}
|
||||
"MFAISA" -> {
|
||||
val binding = FragmentReceiptMfaisaBinding.inflate(inflater, container, false)
|
||||
bindMfaisa(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
}
|
||||
else -> {
|
||||
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
||||
bindBml(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,15 +162,21 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
})
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
||||
shareReceipt()
|
||||
}
|
||||
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||
saveReceipt()
|
||||
}
|
||||
|
||||
val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("always_fullscreen_receipt", false)
|
||||
if (alwaysFullScreen) {
|
||||
// The normal page is skipped: keep it laid out (share/save capture its card) but hidden,
|
||||
// and leave the receipt entirely when the full-screen view is closed
|
||||
view.alpha = 0f
|
||||
view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data binding ──────────────────────────────────────────────────────────
|
||||
@@ -155,43 +184,64 @@ class TransferReceiptFragment : Fragment() {
|
||||
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
||||
val args = requireArguments()
|
||||
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
||||
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
|
||||
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
||||
val toLabel = args.getString(ARG_TO_LABEL, "")
|
||||
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||
val amount = args.getString(ARG_AMOUNT, "")
|
||||
|
||||
// From avatar: initials first, then load profile image if hash available
|
||||
binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor))
|
||||
binding.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel))
|
||||
binding.tvFromLabel.text = fromLabel
|
||||
if (fromProfileHash != null) {
|
||||
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
|
||||
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) }
|
||||
}
|
||||
|
||||
// To avatar: use already-rendered bitmap from TransferFragment if available
|
||||
val toAvatar = pendingToAvatarBitmap
|
||||
if (toAvatar != null) {
|
||||
binding.ivToAvatar.setImageBitmap(toAvatar)
|
||||
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar))
|
||||
} else {
|
||||
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
|
||||
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel))
|
||||
}
|
||||
binding.tvToLabel.text = toLabel
|
||||
|
||||
binding.tvAmount.text = "$currency $amount"
|
||||
|
||||
val toBank = args.getString(ARG_TO_BANK, "")
|
||||
val rawDate = args.getString(ARG_MIB_DATE, "")
|
||||
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
||||
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
|
||||
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
|
||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
||||
binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel }
|
||||
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, ""))
|
||||
.filter { it.isNotBlank() }.joinToString("\n")
|
||||
binding.tvToBank.text = toBank
|
||||
// Receipts saved before the type was recorded fall back to a guess from the bank
|
||||
binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank {
|
||||
if (toBank == "MIB") "MIB Transfer" else "Quick Transfer"
|
||||
}
|
||||
binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm")
|
||||
binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy")
|
||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
|
||||
|
||||
copyOnLongClick(
|
||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
||||
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
||||
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus,
|
||||
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank,
|
||||
binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||
)
|
||||
}
|
||||
|
||||
/** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */
|
||||
private fun formatMibDate(raw: String, pattern: String): String {
|
||||
if (raw.isBlank()) return ""
|
||||
val out = DateTimeFormatter.ofPattern(pattern, Locale.US)
|
||||
for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) {
|
||||
try {
|
||||
return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val sess = app.anyMibSession() ?: return
|
||||
@@ -250,6 +300,49 @@ class TransferReceiptFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun bindMfaisa(binding: FragmentReceiptMfaisaBinding) {
|
||||
val args = requireArguments()
|
||||
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||
val amountStr = args.getString(ARG_AMOUNT, "")
|
||||
|
||||
val formattedAmount = try {
|
||||
val d = amountStr.toDouble()
|
||||
val intFmt = NumberFormat.getNumberInstance(Locale.US).apply { maximumFractionDigits = 0 }
|
||||
intFmt.format(d.toLong()) + "%.2f".format(d).takeLast(3)
|
||||
} catch (_: Exception) { amountStr }
|
||||
|
||||
binding.tvAmount.text = "$currency $formattedAmount"
|
||||
binding.tvTransactionType.text = args.getString(ARG_MFAISA_TXN_TYPE, "")
|
||||
.ifBlank { "Transfer to mobile" }
|
||||
binding.tvFromName.text = args.getString(ARG_MFAISA_FROM_NAME, "")
|
||||
.ifBlank { args.getString(ARG_FROM_LABEL, "") }
|
||||
binding.tvFromMsisdn.text = args.getString(ARG_MFAISA_FROM_MSISDN, "")
|
||||
binding.tvToName.text = args.getString(ARG_TO_LABEL, "")
|
||||
binding.tvToMsisdn.text = args.getString(ARG_MFAISA_TO_MSISDN, "")
|
||||
.ifBlank { args.getString(ARG_TO_ACCOUNT, "") }
|
||||
binding.tvDateTime.text = formatMfaisaTimestamp(args.getLong(ARG_MFAISA_TIMESTAMP, 0L))
|
||||
|
||||
val remarks = args.getString(ARG_REMARKS, "")
|
||||
if (!remarks.isNullOrBlank()) {
|
||||
binding.tvRemarks.text = remarks
|
||||
binding.remarksDivider.visibility = View.VISIBLE
|
||||
binding.remarksRow.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
copyOnLongClick(
|
||||
binding.tvAmount, binding.tvStatus, binding.tvTransactionType,
|
||||
binding.tvFromName, binding.tvFromMsisdn,
|
||||
binding.tvToName, binding.tvToMsisdn,
|
||||
binding.tvDateTime, binding.tvRemarks
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatMfaisaTimestamp(millis: Long): String {
|
||||
val effective = if (millis > 0) millis else System.currentTimeMillis()
|
||||
val sdf = java.text.SimpleDateFormat("EEEE d MMMM yyyy HH:mm:ss z", Locale.US)
|
||||
return sdf.format(java.util.Date(effective))
|
||||
}
|
||||
|
||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun shareReceipt() {
|
||||
@@ -314,21 +407,45 @@ class TransferReceiptFragment : Fragment() {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Captures the receipt card using PixelCopy, which correctly handles
|
||||
* hardware-accelerated views (avoids the black-square problem with view.draw()).
|
||||
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
|
||||
* dimensions, so the captured image isn't affected by the on-screen scale
|
||||
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||
*/
|
||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||
val view = _receiptCard ?: run { callback(null); return }
|
||||
if (view.width == 0 || view.height == 0) { callback(null); return }
|
||||
val shown = _receiptCard ?: run { callback(null); return }
|
||||
if (shown.width == 0 || shown.height == 0) { callback(null); return }
|
||||
|
||||
// BML: the preview follows the app theme, but shared/saved images are always light
|
||||
val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") {
|
||||
inflateLightBmlCard(shown.width)
|
||||
} else shown
|
||||
|
||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||
val location = IntArray(2)
|
||||
view.getLocationInWindow(location)
|
||||
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
view.draw(canvas)
|
||||
callback(bitmap)
|
||||
}
|
||||
|
||||
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
|
||||
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
|
||||
private fun inflateLightBmlCard(widthPx: Int): View {
|
||||
val config = android.content.res.Configuration(resources.configuration).apply {
|
||||
uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply {
|
||||
applyOverrideConfiguration(config)
|
||||
}
|
||||
val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx))
|
||||
bindBml(binding)
|
||||
val card = binding.receiptCard
|
||||
(card.parent as? ViewGroup)?.removeView(card)
|
||||
card.measure(
|
||||
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
|
||||
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
)
|
||||
card.layout(0, 0, card.measuredWidth, card.measuredHeight)
|
||||
return card
|
||||
}
|
||||
|
||||
private fun formatBmlTimestamp(raw: String): String {
|
||||
@@ -340,40 +457,68 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||
/** Center-crops [src] to a square and masks it to a circle. */
|
||||
private fun circleCrop(src: Bitmap): Bitmap {
|
||||
val size = minOf(src.width, src.height)
|
||||
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
|
||||
shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply {
|
||||
setLocalMatrix(android.graphics.Matrix().apply {
|
||||
setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f)
|
||||
})
|
||||
}
|
||||
}
|
||||
Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||
return out
|
||||
}
|
||||
|
||||
/** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */
|
||||
private fun makeMibInitialsBitmap(name: String): Bitmap {
|
||||
val sizePx = (resources.displayMetrics.density * 52).toInt()
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.color = bgColor
|
||||
paint.color = Color.parseColor("#C6E1FD")
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = sizePx * 0.42f
|
||||
paint.color = Color.parseColor("#1168F3")
|
||||
paint.textSize = sizePx * 0.36f
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
paint.typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||
val initials = name.split(Regex("\\s+"))
|
||||
.mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() }
|
||||
.take(2).joinToString("").ifEmpty { "?" }
|
||||
val metrics = paint.fontMetrics
|
||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
return bm
|
||||
}
|
||||
|
||||
private fun showFullScreenReceipt() {
|
||||
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
|
||||
val ctx = requireContext()
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
|
||||
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
|
||||
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
|
||||
val cardView = when (bank) {
|
||||
"MIB" -> {
|
||||
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||
bindMib(binding)
|
||||
binding.receiptCard
|
||||
}
|
||||
"MFAISA" -> {
|
||||
val binding = FragmentReceiptMfaisaBinding.inflate(layoutInflater)
|
||||
bindMfaisa(binding)
|
||||
binding.receiptCard
|
||||
}
|
||||
else -> {
|
||||
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
||||
bindBml(binding)
|
||||
binding.receiptCard
|
||||
}
|
||||
}
|
||||
(cardView.parent as? ViewGroup)?.removeView(cardView)
|
||||
cardView.setOnClickListener { dialog.dismiss() }
|
||||
@@ -393,6 +538,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||
if (closePageOnDismiss) closeReceiptPage()
|
||||
}
|
||||
dialog.show()
|
||||
dialog.window?.let { win ->
|
||||
@@ -404,6 +550,121 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BML full-screen receipt: status bar stays visible, top bar with back button,
|
||||
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
||||
* Follows the app theme (light/dark).
|
||||
*/
|
||||
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||
|
||||
val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard
|
||||
(card.parent as? ViewGroup)?.removeView(card)
|
||||
page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
|
||||
val topBasePadding = page.topBar.paddingTop
|
||||
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
page.topBar.updatePadding(top = topBasePadding + bars.top)
|
||||
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||
insets
|
||||
}
|
||||
|
||||
dialog.setContentView(page.root)
|
||||
dialog.window?.let { win ->
|
||||
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||
@Suppress("DEPRECATION")
|
||||
win.statusBarColor = Color.TRANSPARENT
|
||||
@Suppress("DEPRECATION")
|
||||
win.navigationBarColor = Color.TRANSPARENT
|
||||
val isLight = (resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* MIB full-screen receipt: edge-to-edge card whose green header runs under the
|
||||
* (visible) status bar, a floating close button top-right just below the status bar,
|
||||
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
|
||||
*/
|
||||
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||
val ctx = requireContext()
|
||||
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||
|
||||
val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) }
|
||||
val card = cardBinding.receiptCard
|
||||
(card.parent as? ViewGroup)?.removeView(card)
|
||||
page.cardHolder.addView(card, ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
))
|
||||
|
||||
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||
|
||||
val header = cardBinding.receiptHeader
|
||||
val headerBaseHeight = header.layoutParams.height
|
||||
val headerBasePadding = header.paddingTop
|
||||
val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin
|
||||
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
// Grow the green header under the status bar, keeping its content below it
|
||||
header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top }
|
||||
header.updatePadding(top = headerBasePadding + bars.top)
|
||||
page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams)
|
||||
.apply { topMargin = closeBaseMargin + bars.top }
|
||||
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||
insets
|
||||
}
|
||||
|
||||
dialog.setContentView(page.root)
|
||||
dialog.window?.let { win ->
|
||||
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||
@Suppress("DEPRECATION")
|
||||
win.statusBarColor = Color.TRANSPARENT
|
||||
@Suppress("DEPRECATION")
|
||||
win.navigationBarColor = Color.TRANSPARENT
|
||||
val isLight = (resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||
// Status bar sits over the green header, so always use light icons
|
||||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/** Pops this receipt off the back stack, returning to whatever screen opened it. */
|
||||
private fun closeReceiptPage() {
|
||||
if (!isAdded || parentFragmentManager.isStateSaved) return
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
|
||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||
for (tv in views) {
|
||||
tv.setOnLongClickListener {
|
||||
@@ -423,13 +684,9 @@ class TransferReceiptFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
_receiptCard = null
|
||||
pendingToAvatarBitmap = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
/**
|
||||
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
|
||||
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
|
||||
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
|
||||
*
|
||||
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
|
||||
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
|
||||
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
|
||||
* BML-specific.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
|
||||
* the view is recreated.
|
||||
*/
|
||||
class BmlTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Asks the fragment to drop the selected source and show the empty From picker. */
|
||||
private val clearSource: () -> Unit,
|
||||
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||
private val onStateChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||
private var otpState = OtpState.NONE
|
||||
set(value) {
|
||||
// The whole OTP flow counts as a payment in flight: a theme change mid-way would
|
||||
// otherwise recreate the screen between initiate and confirm.
|
||||
if (field == OtpState.NONE && value != OtpState.NONE) {
|
||||
otpGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||
} else if (value == OtpState.NONE) {
|
||||
otpGuard?.end()
|
||||
otpGuard = null
|
||||
}
|
||||
field = value
|
||||
}
|
||||
private var otpGuard: HomeActivity.PaymentGuard? = null
|
||||
private var otpChannel: String? = null
|
||||
|
||||
private data class PendingTransfer(
|
||||
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 pendingTransfer: PendingTransfer? = null
|
||||
|
||||
// Merchant QR state lives in the draft so it outlives this handler (dropped with the view).
|
||||
private val draft get() = viewModel.transferDraft
|
||||
|
||||
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
|
||||
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
|
||||
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
|
||||
private var qrLookupInFlight = false
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
|
||||
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
|
||||
|
||||
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
|
||||
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
|
||||
|
||||
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||
|
||||
fun sessionFor(account: BankAccount?): BmlSession? =
|
||||
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
|
||||
|
||||
fun isBusinessProfile(account: BankAccount): Boolean {
|
||||
val loginId = account.loginTag.removePrefix("bml_")
|
||||
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
|
||||
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
|
||||
* resolve it, so the caller can fall back to the MIB IPS lookup.
|
||||
*
|
||||
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
|
||||
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
|
||||
* endpoint leaves `currency` blank — the caller enriches it via
|
||||
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
|
||||
*/
|
||||
fun validateDestination(
|
||||
session: BmlSession,
|
||||
accountNumber: String,
|
||||
verifyAsMib: Boolean
|
||||
): MibIpsAccountInfo? {
|
||||
val result = try {
|
||||
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
|
||||
else BmlValidateClient().validateAccount(session, accountNumber)
|
||||
} catch (_: Exception) { null } ?: return null
|
||||
val bankId = when (result.trnType) {
|
||||
"IAT" -> "MALBMVMV"
|
||||
else -> result.agnt ?: result.account
|
||||
}
|
||||
return MibIpsAccountInfo(
|
||||
accountName = result.name,
|
||||
accountNumber = result.account,
|
||||
bankId = bankId,
|
||||
currency = result.currency
|
||||
)
|
||||
}
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
draft.pendingBmlQrTarget = null
|
||||
if (qrInfo == null) return
|
||||
draft.bmlQrInfo = null
|
||||
draft.bmlGatewayQr = false
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
|
||||
fun clearState() {
|
||||
otpState = OtpState.NONE
|
||||
otpChannel = null
|
||||
pendingTransfer = null
|
||||
}
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode.
|
||||
* Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment
|
||||
* retries once sessions load (cold start) or when the view comes back (tab switched away
|
||||
* mid-lookup).
|
||||
*/
|
||||
fun lookupQrMerchant(qrUrl: String) {
|
||||
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
|
||||
val draft = this.draft
|
||||
draft.pendingBmlQrTarget = qrUrl
|
||||
if (qrLookupInFlight) return
|
||||
val session = app.anyBmlSession() ?: return
|
||||
qrLookupInFlight = true
|
||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||
|
||||
// The To row stays on screen with a spinner while loading and is only swapped for the
|
||||
// merchant card once there is a merchant to show — hiding it up front left a gap that
|
||||
// made the form jump twice.
|
||||
fragment.startLookupLoading()
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
qrLookupInFlight = false
|
||||
if (fragment.view == null) return@launch
|
||||
if (draft !== viewModel.transferDraft) return@launch
|
||||
fragment.stopLookupLoading()
|
||||
// Superseded: cleared meanwhile, or another QR was opened while this one ran
|
||||
val latest = draft.pendingBmlQrTarget
|
||||
if (latest != qrUrl) {
|
||||
latest?.let { lookupQrMerchant(it) }
|
||||
return@launch
|
||||
}
|
||||
draft.pendingBmlQrTarget = null
|
||||
val info = result.getOrNull()
|
||||
if (info == null) {
|
||||
// An expired or rejected QR is BML telling us something specific — show its own
|
||||
// wording and stay put with the To row as it was, rather than bouncing the user
|
||||
// out of the screen they just scanned from.
|
||||
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
draft.bmlQrInfo = info
|
||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount
|
||||
// and need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||
draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||
if (info.amount == 0.0) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "bmlqr:$qrUrl",
|
||||
displayName = info.merchantName,
|
||||
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||
colorHex = "#0066A1",
|
||||
imageHash = null,
|
||||
isProfileImage = false
|
||||
))
|
||||
}
|
||||
|
||||
// Hide the To row before touching the source: repainting the From card re-syncs the
|
||||
// picker/scan buttons to the To row's visibility.
|
||||
hideToRow()
|
||||
|
||||
// Only a BML card can pay a merchant QR — drop any other source, then auto-select
|
||||
// the user's default card if no card was pre-selected
|
||||
if (currentSource()?.let { isCard(it) } == false) clearSource()
|
||||
if (currentSource() == null) {
|
||||
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||
val defaultCard = allAccounts.firstOrNull {
|
||||
it.accountNumber == defaultNum && isCard(it) &&
|
||||
it.statusDesc.equals("Active", ignoreCase = true)
|
||||
}
|
||||
if (defaultCard != null) selectSource(defaultCard)
|
||||
}
|
||||
}
|
||||
|
||||
showQrMerchant(info)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a
|
||||
* recreated view restores it — no network involved.
|
||||
*/
|
||||
fun showQrMerchant(info: BmlQrPayInfo) {
|
||||
hideToRow()
|
||||
// Clear button hidden (can't change recipient for QR)
|
||||
binding.tvToAccountName.text = info.merchantName
|
||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill amount if dynamic QR
|
||||
if (info.amount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(info.amount))
|
||||
fragment.setAmountLocked(true)
|
||||
}
|
||||
|
||||
// Remarks not applicable for merchant QR payments
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
|
||||
onStateChanged()
|
||||
}
|
||||
|
||||
private fun hideToRow() {
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
|
||||
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||
*/
|
||||
fun submitQrPayment() {
|
||||
val info = qrInfo ?: return
|
||||
val src = currentSource() ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
Toast.makeText(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = info.currency,
|
||||
amountValue = "%.2f".format(amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = fromDetail,
|
||||
toName = info.merchantName,
|
||||
toNumber = "",
|
||||
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeQrPayment(
|
||||
src: BankAccount,
|
||||
debitAccount: String,
|
||||
info: BmlQrPayInfo,
|
||||
amount: Double,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val session = sessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (gatewayQr) {
|
||||
val preOk = BmlQrPayClient().preInitiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!preOk) return@withContext null
|
||||
}
|
||||
val initiated = BmlQrPayClient().initiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!initiated) return@withContext null
|
||||
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)
|
||||
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||
BmlQrPayClient().confirmPayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||
} catch (e: Exception) {
|
||||
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
|
||||
if (result == null) {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
if (result.success) {
|
||||
fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = result.currency.ifBlank { info.currency },
|
||||
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
fromName = src.accountBriefName,
|
||||
toName = result.merchant.ifBlank { info.merchantName }
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
}
|
||||
} else {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card-only merchant payment (no BML Pay) ─────────────────────────────
|
||||
|
||||
/** A card-only BML merchant is loaded — the fragment treats it like the QR merchant mode. */
|
||||
val hasCardMerchant: Boolean get() = cardMerchant != null
|
||||
private val cardMerchant get() = draft.bmlCardMerchant
|
||||
|
||||
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
|
||||
private fun verifiedCardCandidates(): List<BankAccount> =
|
||||
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
|
||||
|
||||
/**
|
||||
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
|
||||
* a card payment: paints the merchant as the recipient, locks the amount, and limits the source
|
||||
* to the user's verified BML cards. Send then runs the Pomelo + 3-D Secure flow.
|
||||
*/
|
||||
fun payCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||
if (page.isPaid) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_already_paid, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (verifiedCardCandidates().isEmpty()) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
draft.bmlCardMerchant = page
|
||||
showCardMerchant(page)
|
||||
|
||||
// Default to a verified card if nothing suitable is already selected.
|
||||
if (currentSource()?.let { isCardVerified(it) } != true) {
|
||||
clearSource()
|
||||
val candidates = verifiedCardCandidates()
|
||||
val default = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||
(candidates.firstOrNull { it.accountNumber == default } ?: candidates.firstOrNull())
|
||||
?.let { selectSource(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCardVerified(account: BankAccount): Boolean = BmlVerifiedCards.isPayable(ctx, account)
|
||||
|
||||
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
|
||||
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||
hideToRow()
|
||||
binding.tvToAccountName.text = page.merchantName
|
||||
binding.tvToBankBic.text = page.merchantAddress.ifBlank { "BML Merchant" }
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(page.merchantName, "#0066A1"))
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
binding.etAmount.setText("%.2f".format(page.amount))
|
||||
fragment.setAmountLocked(true)
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
onStateChanged()
|
||||
}
|
||||
|
||||
/** Drops the loaded card merchant and unlocks the amount/remarks fields. */
|
||||
fun clearCardMerchant() {
|
||||
if (cardMerchant == null) return
|
||||
draft.bmlCardMerchant = null
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
/** Confirm-then-pay for the loaded card merchant, using the selected verified card. */
|
||||
fun submitCardPayment() {
|
||||
val page = cardMerchant ?: return
|
||||
val src = currentSource()
|
||||
if (src == null || !isCardVerified(src)) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
confirmCardMerchant(page, src)
|
||||
}
|
||||
|
||||
/**
|
||||
* The card payment's confirm dialog (biometric-gated), then the Pomelo + 3-D Secure payment of
|
||||
* [page] with [src]. Also the send step for carrier services paid by card
|
||||
* ([CardPayoutTransferHandler]), once the carrier has created the BML transaction.
|
||||
*/
|
||||
fun confirmCardMerchant(
|
||||
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
|
||||
src: BankAccount
|
||||
) {
|
||||
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||
val warnings = listOf(
|
||||
"⚠ ${page.merchantName} does not support BML Pay. This transaction will be paid via card. " +
|
||||
"Card payments can be less reliable, and this can take up to a minute to complete. " +
|
||||
"Please keep the app open and don't retry if it seems slow."
|
||||
)
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = fromDetail,
|
||||
toName = page.merchantName,
|
||||
toNumber = "",
|
||||
toDetail = page.merchantAddress.ifBlank { "BML Merchant" },
|
||||
warningTexts = warnings
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${page.currency} ${"%.2f".format(page.amount)} → ${page.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeCardMerchant(page, src, dialog, frame)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeCardMerchant(
|
||||
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
|
||||
src: BankAccount,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
BmlMerchantCardPayClient().pay(page, card) { retry ->
|
||||
// A code about to roll over can expire before the ACS checks it, and a
|
||||
// retry in the same window would resend the rejected code: wait for the
|
||||
// next window in both cases. Runs on IO.
|
||||
val left = TOTP_WINDOW_MS - System.currentTimeMillis() % TOTP_WINDOW_MS
|
||||
if (retry || left < TOTP_MIN_LEFT_MS) Thread.sleep(left + 500)
|
||||
Totp.generate(otpSeed)
|
||||
}
|
||||
}.getOrElse {
|
||||
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
when (result) {
|
||||
is BmlMerchantCardPayClient.Result.Success -> {
|
||||
fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = page.currency,
|
||||
amountValue = "%.2f".format(page.amount),
|
||||
fromName = src.accountBriefName,
|
||||
toName = page.merchantName
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
}
|
||||
// Charged, but the merchant may not deliver until it's told
|
||||
if (!result.merchantNotified) {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.bml_card_pay_merchant_not_notified,
|
||||
page.merchantName, page.transactionId), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
is BmlMerchantCardPayClient.Result.Failure -> {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||
|
||||
/**
|
||||
* Runs initiate + confirm on the calling (IO) thread and returns
|
||||
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
|
||||
*/
|
||||
fun doTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destDisplay: String,
|
||||
amount: Double,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: return Triple(false, "OTP unavailable", null)
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
|
||||
}
|
||||
|
||||
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||
?: return Triple(false, "BML contact not found for this account", null)
|
||||
val (transferType, creditAccount, bank) = routed
|
||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||
|
||||
// Step 1: initiate
|
||||
val initiated = try {
|
||||
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
|
||||
|
||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||
|
||||
// Step 2: confirm with fresh OTP
|
||||
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) } ?: otp
|
||||
|
||||
return try {
|
||||
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "BML",
|
||||
amount = "%.2f".format(amount),
|
||||
currency = currency,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#0066A1",
|
||||
toLabel = destDisplay.ifBlank { destAccount },
|
||||
toAccount = destAccount,
|
||||
toBank = toBank,
|
||||
remarks = remarks,
|
||||
bmlFromName = src.accountBriefName,
|
||||
bmlReference = result.reference,
|
||||
bmlTimestamp = result.timestamp,
|
||||
bmlMessage = result.message
|
||||
)
|
||||
val time = result.timestamp.take(19).replace("T", " ")
|
||||
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Business profile OTP flow ───────────────────────────────────────────
|
||||
|
||||
fun startBusinessOtpFlow(
|
||||
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(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||
if (routed == null) {
|
||||
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val (transferType, creditAccount, bank) = routed
|
||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||
|
||||
pendingTransfer = PendingTransfer(
|
||||
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
|
||||
)
|
||||
|
||||
otpState = OtpState.SELECTING_CHANNEL
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val sess = sessionFor(src)
|
||||
val channels = if (sess != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||
catch (_: Exception) { emptyList() }
|
||||
}
|
||||
} else emptyList<BmlOtpChannel>()
|
||||
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (channels.isEmpty()) {
|
||||
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
|
||||
showChannelSelection(channels)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
|
||||
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 { selectOtpChannel(channel) }
|
||||
binding.containerBmlChannels.addView(row)
|
||||
}
|
||||
|
||||
disableTransferFields()
|
||||
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun selectOtpChannel(channel: BmlOtpChannel) {
|
||||
otpChannel = channel.channel
|
||||
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||
|
||||
val pending = pendingTransfer ?: return
|
||||
val sess = sessionFor(pending.src) ?: run {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.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 }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (!initiated) {
|
||||
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
|
||||
otpState = OtpState.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 = ctx.getString(R.string.transfer_verify_payment)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
|
||||
fun verifyOtp() {
|
||||
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||
if (otp.isEmpty()) {
|
||||
binding.tilBmlOtp.error = "Enter the verification code"
|
||||
return
|
||||
}
|
||||
binding.tilBmlOtp.error = null
|
||||
val pending = pendingTransfer ?: return
|
||||
val channel = otpChannel ?: return
|
||||
val sess = sessionFor(pending.src) ?: run {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
val capturedToAvatar = pending.toAvatar
|
||||
|
||||
fragment.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, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
|
||||
}
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (ok && receipt != null) {
|
||||
resetOtpState()
|
||||
onTransferSuccess(receipt, capturedToAvatar)
|
||||
} else {
|
||||
binding.btnTransfer.isEnabled = true
|
||||
if (msg == "CONNECTIVITY") {
|
||||
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
|
||||
} else {
|
||||
binding.tilBmlOtp.error = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
|
||||
fun resetOtpState() {
|
||||
otpState = OtpState.NONE
|
||||
otpChannel = null
|
||||
pendingTransfer = null
|
||||
if (fragment.view == null) return
|
||||
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||
binding.tvBmlOtpSentVia.visibility = View.GONE
|
||||
binding.tilBmlOtp.visibility = View.GONE
|
||||
binding.etBmlOtp.setText("")
|
||||
binding.tilBmlOtp.error = null
|
||||
enableTransferFields()
|
||||
binding.btnTransfer.text = ctx.getString(R.string.transfer)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ─── Shared routing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Picks the BML transfer type and the credit-account identifier the API expects.
|
||||
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
|
||||
* callers report as "BML contact not found".
|
||||
*/
|
||||
private fun routeTransfer(
|
||||
destAccount: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>
|
||||
): Triple<String, String, String?>? {
|
||||
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
|
||||
return when {
|
||||
isSrcCard -> {
|
||||
// CAD: card → own BML account
|
||||
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
|
||||
}
|
||||
isDestMyCard -> {
|
||||
// CPA: BML CASA → own card top-up
|
||||
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
|
||||
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
|
||||
}
|
||||
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||
isDestMib -> {
|
||||
// USD DOT: requires BML contact numeric ID
|
||||
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||
?: return null
|
||||
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
|
||||
}
|
||||
else -> Triple("IAT", destAccount, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
|
||||
|
||||
private companion object {
|
||||
/** The BML token's TOTP window. */
|
||||
const val TOTP_WINDOW_MS = 30_000L
|
||||
/** Don't send a card payment's 3-D Secure code with less than this left in its window. */
|
||||
const val TOTP_MIN_LEFT_MS = 5_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.content.Context
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.VerifiedCardStore
|
||||
|
||||
/**
|
||||
* BML cards that can pay a merchant by card + 3-D Secure: the card is verified (full details in
|
||||
* [VerifiedCardStore]) and belongs to a BML login we hold the OTP seed for, since the 3-D Secure
|
||||
* step is answered with that login's token code.
|
||||
*/
|
||||
object BmlVerifiedCards {
|
||||
|
||||
/** A payable card's details, ready for [BmlMerchantCardPayClient.pay]. */
|
||||
data class Payable(val card: BmlMerchantCardPayClient.Card, val otpSeed: String)
|
||||
|
||||
fun isCard(account: BankAccount) =
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
|
||||
fun isPayable(ctx: Context, account: BankAccount): Boolean =
|
||||
isCard(account) && VerifiedCardStore.isVerified(ctx, key(account)) && otpSeed(ctx, account) != null
|
||||
|
||||
fun payable(ctx: Context, accounts: List<BankAccount>): List<BankAccount> {
|
||||
val verified = VerifiedCardStore.keys(ctx)
|
||||
return accounts.filter { isCard(it) && key(it) in verified && otpSeed(ctx, it) != null }
|
||||
}
|
||||
|
||||
/** The stored card details and OTP seed for [account], or null when it isn't payable. */
|
||||
fun load(ctx: Context, account: BankAccount): Payable? {
|
||||
val stored = VerifiedCardStore.load(ctx, key(account)) ?: return null
|
||||
val seed = otpSeed(ctx, account) ?: return null
|
||||
val expiry = stored.expiry.split("/") // "MM/YY"
|
||||
if (expiry.size != 2) return null
|
||||
return Payable(
|
||||
card = BmlMerchantCardPayClient.Card(
|
||||
pan = stored.pan,
|
||||
expiryMonth = expiry[0].padStart(2, '0'),
|
||||
expiryYear = expiry[1].takeLast(2),
|
||||
cvv = stored.cvv,
|
||||
holderName = account.accountBriefName
|
||||
),
|
||||
otpSeed = seed
|
||||
)
|
||||
}
|
||||
|
||||
private fun key(account: BankAccount) = "bml:${account.accountNumber}"
|
||||
|
||||
private fun otpSeed(ctx: Context, account: BankAccount) =
|
||||
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
import sh.sar.basedbank.api.ooredoo.OoredooPaymentClient
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* A carrier service a verified BML card can pay, through the carrier's own website and its BML
|
||||
* merchant gateway. The limits are the carrier website's, not Fahipay's.
|
||||
*
|
||||
* Add new services as constants; the exhaustive `when`s over this enum point at every site that
|
||||
* needs updating.
|
||||
*/
|
||||
enum class CardPayoutService(
|
||||
override val label: String,
|
||||
override val destinationLabel: String,
|
||||
@param:DrawableRes override val iconRes: Int,
|
||||
override val minAmount: Int,
|
||||
override val maxAmount: Int?,
|
||||
override val decimalsAllowed: Boolean,
|
||||
override val gstPercent: Int?,
|
||||
/**
|
||||
* Tags the recents paid with this service (`RecentPick.contactCategory`), so picking one
|
||||
* again pays the same way. Not a real contact list: card payouts have no favourites.
|
||||
*/
|
||||
val contactCategory: String,
|
||||
override val gstAdded: Boolean = false,
|
||||
) : PayoutService {
|
||||
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
|
||||
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8,
|
||||
contactCategory = "CARD_DHIRAAGU_RELOAD") {
|
||||
// Dhiraagu rounds the GST to 2 places and credits the rest
|
||||
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
|
||||
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
|
||||
},
|
||||
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
|
||||
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
|
||||
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null,
|
||||
contactCategory = "CARD_DHIRAAGU_BILL"),
|
||||
// Ooredoo credits the whole amount and charges the card 8% GST on top
|
||||
OOREDOO_RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||
minAmount = 20, maxAmount = null, decimalsAllowed = false, gstPercent = 8,
|
||||
contactCategory = "CARD_RAASTAS", gstAdded = true),
|
||||
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
|
||||
minAmount = 10, maxAmount = null, decimalsAllowed = true, gstPercent = null,
|
||||
contactCategory = "CARD_OOREDOO_BILL");
|
||||
|
||||
companion object {
|
||||
/** The service a recent was paid with, from its [contactCategory], or null when it isn't one. */
|
||||
fun fromContactCategory(categoryId: String?): CardPayoutService? =
|
||||
entries.firstOrNull { it.contactCategory == categoryId }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the Transfer screen's "carrier service by BML card" parts: which services a looked-up
|
||||
* number can be paid with by card, and the picked service's amount rules.
|
||||
*
|
||||
* Sending only differs from paying a card-only BML merchant link in where the BML transaction
|
||||
* comes from: the carrier's website creates it for the number and amount. From there it goes
|
||||
* through [BmlTransferHandler.confirmCardMerchant] — the same confirm dialog, card + 3-D Secure
|
||||
* payment and result as a pasted link.
|
||||
*
|
||||
* Mirrors [FahipayTransferHandler]: the fragment keeps the recipient card and the form state.
|
||||
* Lifetime is bound to the fragment's view.
|
||||
*/
|
||||
class CardPayoutTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
private val bmlHandler: () -> BmlTransferHandler,
|
||||
) {
|
||||
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val amountField = PayoutAmountField(binding) { ctx }
|
||||
|
||||
/** The service picked for the current recipient; null when none is. */
|
||||
var service: CardPayoutService?
|
||||
get() = viewModel.transferDraft.cardPayoutService
|
||||
private set(value) { viewModel.transferDraft.cardPayoutService = value }
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Account numbers of the cards that can pay by card. Call on the main thread. */
|
||||
fun payableCards(): Set<String> =
|
||||
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
|
||||
.map { it.accountNumber }.toSet()
|
||||
|
||||
/** The card transfer types a carrier lookup [result] allows, payable from [cards]. */
|
||||
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
|
||||
if (cards.isEmpty()) return emptyList()
|
||||
return buildList {
|
||||
when (result.dhiraagu.type) {
|
||||
DhiraaguClient.CustType.RELOAD -> add(CardPayoutService.DHIRAAGU_RELOAD)
|
||||
DhiraaguClient.CustType.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
|
||||
DhiraaguClient.CustType.UNSUPPORTED -> {}
|
||||
}
|
||||
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) {
|
||||
add(CardPayoutService.OOREDOO_RAASTAS)
|
||||
}
|
||||
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) {
|
||||
add(CardPayoutService.OOREDOO_BILL)
|
||||
}
|
||||
}.map { TransferType.Card(it, result.ownerName, cards) }
|
||||
}
|
||||
|
||||
/** Forgets the picked service and gives back the amount and reference fields. */
|
||||
fun clearState() {
|
||||
if (service == null) return
|
||||
service = null
|
||||
amountField.reset()
|
||||
}
|
||||
|
||||
/** Why the typed amount can't be sent with the picked service, or null when it can. */
|
||||
val amountProblem: String?
|
||||
get() = service?.let(amountField::problem)
|
||||
|
||||
/** Keeps the amount and reference fields in step with the picked service. */
|
||||
fun syncAmountField() {
|
||||
service?.let(amountField::sync)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the recipient card for a picked card transfer type. The fragment has already
|
||||
* switched the source to one of the type's cards.
|
||||
*/
|
||||
fun applyService(type: TransferType.Card, number: String) {
|
||||
service = type.service
|
||||
// None of the payouts take a reference; syncAmountField() disables the box
|
||||
binding.etRemarks.setText("")
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val displayName = type.ownerName
|
||||
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||
?: number
|
||||
fragment.prefillToDirectly(
|
||||
accountNumber = number,
|
||||
displayName = displayName,
|
||||
subtitle = "${type.label} · $number",
|
||||
colorHex = "#E4002B",
|
||||
imageHash = null,
|
||||
contactCategory = type.service.contactCategory
|
||||
)
|
||||
fragment.focusAmount()
|
||||
}
|
||||
|
||||
// ─── Send ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Has the carrier create the BML transaction for the number and amount, then hands its
|
||||
* payment page to the card merchant flow. Nothing is charged until that flow's confirm.
|
||||
*/
|
||||
fun submit() {
|
||||
val svc = service ?: return
|
||||
val src = viewModel.transferDraft.selectedAccount
|
||||
if (src == null || !BmlVerifiedCards.isPayable(ctx, src)) {
|
||||
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
val number = viewModel.transferDraft.transferTypeNumber
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
|
||||
val charged = svc.chargedWithGst(amount)
|
||||
|
||||
// Creating the order takes a few round trips; show the payment's processing box meanwhile
|
||||
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val page = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val txnId = when (svc) {
|
||||
CardPayoutService.DHIRAAGU_RELOAD ->
|
||||
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
|
||||
CardPayoutService.DHIRAAGU_BILL ->
|
||||
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
|
||||
CardPayoutService.OOREDOO_RAASTAS ->
|
||||
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
|
||||
CardPayoutService.OOREDOO_BILL ->
|
||||
OoredooPaymentClient().createBillPayTransaction(number, amount)
|
||||
}
|
||||
BmlMerchantTxnClient().fetchPayPage(txnId)
|
||||
}
|
||||
}
|
||||
processing.dismiss()
|
||||
if (fragment.view == null) return@launch
|
||||
page.onSuccess {
|
||||
when {
|
||||
!it.supportsCard ->
|
||||
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
// The carrier's order must be for exactly what was typed (plus GST, if added)
|
||||
it.amount.toBigDecimal().compareTo(charged) != 0 ->
|
||||
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
else -> bmlHandler().confirmCardMerchant(it, src)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> ctx.getString(R.string.transfer_bml_txn_lookup_failed)
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
|
||||
/**
|
||||
* Which carrier a phone number is on, and how it's billed. Feeds both payout routes: the
|
||||
* Fahipay services and the BML card services each map this to what they can pay.
|
||||
*/
|
||||
object CarrierLookup {
|
||||
|
||||
data class Result(
|
||||
val dhiraagu: DhiraaguClient.Result,
|
||||
val ooredoo: OoredooClient.CustType,
|
||||
) {
|
||||
/** Dhiraagu is the only carrier that hands back an owner name. */
|
||||
val ownerName: String? get() = dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||
*/
|
||||
fun query(number: String): Result =
|
||||
if (number.startsWith("7")) {
|
||||
// Dhiraagu first, fall back to Ooredoo
|
||||
val d = dhiraagu(number)
|
||||
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||
else OoredooClient.CustType.UNSUPPORTED
|
||||
Result(d, o)
|
||||
} else {
|
||||
// Ooredoo first, fall back to Dhiraagu
|
||||
val o = ooredoo(number)
|
||||
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||
Result(d, o)
|
||||
}
|
||||
|
||||
private fun dhiraagu(number: String) =
|
||||
try { DhiraaguClient().validateNumber(number) }
|
||||
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||
|
||||
private fun ooredoo(number: String) =
|
||||
try { OoredooClient().validateNumber(number) }
|
||||
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayPaymentClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
/**
|
||||
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||
* a given number. The limits are Fahipay's — the same carrier service paid by card
|
||||
* ([CardPayoutService]) has its own.
|
||||
*
|
||||
* Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path
|
||||
* lands, and the exhaustive `when`s over this enum will point at every site that needs updating.
|
||||
*/
|
||||
enum class FahipayService(
|
||||
override val label: String,
|
||||
override val destinationLabel: String,
|
||||
@param:DrawableRes override val iconRes: Int,
|
||||
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
|
||||
val paymentPath: String,
|
||||
override val minAmount: Int,
|
||||
override val maxAmount: Int?,
|
||||
override val decimalsAllowed: Boolean,
|
||||
/**
|
||||
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
|
||||
* set by `FahipayContactsClient`). Recents of this service are tagged with it too.
|
||||
*/
|
||||
val contactCategory: String,
|
||||
override val gstPercent: Int? = null,
|
||||
) : PayoutService {
|
||||
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||
paymentPath = "actions/payment/ooredoo/recharge/",
|
||||
minAmount = 11, maxAmount = null, decimalsAllowed = false,
|
||||
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
|
||||
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
|
||||
paymentPath = "actions/payment/ooredoo/billpay/",
|
||||
minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
|
||||
contactCategory = "FAHIPAY_OOREDOO_BILL"),
|
||||
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
|
||||
paymentPath = "actions/payment/dhiraagu/recharge/",
|
||||
minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
|
||||
contactCategory = "FAHIPAY_RELOAD"),
|
||||
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
|
||||
paymentPath = "actions/payment/dhiraagu/billpay/",
|
||||
minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
|
||||
contactCategory = "FAHIPAY_DHIRAAGU_BILL");
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The service a saved Fahipay favourite or recent pays with, from its [contactCategory],
|
||||
* or null when [categoryId] isn't one of them.
|
||||
*/
|
||||
fun fromContactCategory(categoryId: String?): FahipayService? =
|
||||
entries.firstOrNull { it.contactCategory == categoryId }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
|
||||
* number into a set of payable services, offering those in the fragment's "Transfer Type"
|
||||
* picker, and the selected service that the confirm dialog labels the destination with.
|
||||
*
|
||||
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
|
||||
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
|
||||
*
|
||||
* [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog
|
||||
* and an in-dialog success screen (no receipt page yet).
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
|
||||
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class FahipayTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
) {
|
||||
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||
var service: FahipayService?
|
||||
get() = viewModel.transferDraft.fahipayService
|
||||
private set(value) { viewModel.transferDraft.fahipayService = value }
|
||||
|
||||
private val amountField = PayoutAmountField(binding) { ctx }
|
||||
|
||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
|
||||
* else is rejected inline on the "To" field.
|
||||
*/
|
||||
fun lookupRecipient(rawInput: String) {
|
||||
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
|
||||
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
|
||||
return
|
||||
}
|
||||
lookupCarrier(rawInput)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets the selected service and gives back the amount and reference fields it took over.
|
||||
* The picker itself is the fragment's to reset.
|
||||
*/
|
||||
fun clearState() {
|
||||
if (service == null) return
|
||||
service = null
|
||||
amountField.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the typed amount can't be sent with the selected service, or null when it can (or the
|
||||
* field is empty, or no service is selected).
|
||||
*/
|
||||
val amountProblem: String?
|
||||
get() = service?.let(amountField::problem)
|
||||
|
||||
/**
|
||||
* Keeps the amount and reference fields in step with the selected service. A no-op when no
|
||||
* service is selected; [clearState] puts the fields back.
|
||||
*/
|
||||
fun syncAmountField() {
|
||||
service?.let(amountField::sync)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the recipient card for a picked Fahipay transfer type. The fragment has already
|
||||
* switched the source to a Fahipay account.
|
||||
*/
|
||||
fun applyService(type: TransferType.Fahipay, number: String) {
|
||||
service = type.service
|
||||
// None of the payouts take a reference; syncAmountField() disables the box
|
||||
binding.etRemarks.setText("")
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val displayName = type.ownerName
|
||||
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||
?: number
|
||||
fragment.prefillToDirectly(
|
||||
accountNumber = number,
|
||||
displayName = displayName,
|
||||
subtitle = "${type.label} · $number",
|
||||
colorHex = "#FF6B00",
|
||||
imageHash = null,
|
||||
contactCategory = type.service.contactCategory
|
||||
)
|
||||
fragment.focusAmount()
|
||||
}
|
||||
|
||||
|
||||
/** The Fahipay transfer types a carrier lookup [result] allows. */
|
||||
fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
|
||||
servicesFor(result).map { TransferType.Fahipay(it, result.ownerName) }
|
||||
|
||||
// ─── Send ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pays the picked service: confirm dialog (with the GST note for services that charge it),
|
||||
* biometric gate, then the payment POST. Success shows in the dialog; a refusal closes it
|
||||
* and toasts the server's message.
|
||||
*/
|
||||
fun submit() {
|
||||
val svc = service ?: return
|
||||
val src = viewModel.transferDraft.selectedAccount?.takeIf { it.bank == "FAHIPAY" } ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val number = viewModel.transferDraft.transferTypeNumber
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
|
||||
// Whole-number services get "11", never "11.00"
|
||||
val amountParam = amount.stripTrailingZeros().toPlainString()
|
||||
val amountDisplay = "%,.2f".format(amount)
|
||||
val toName = binding.tvToAccountName.text?.toString().orEmpty().ifBlank { number }
|
||||
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = "MVR",
|
||||
amountValue = amountDisplay,
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = "Fahipay",
|
||||
toName = toName,
|
||||
toNumber = number,
|
||||
toDetail = svc.destinationLabel,
|
||||
warningTexts = listOfNotNull(amountField.gstNote(svc))
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "MVR $amountDisplay → ${svc.label} $number",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
pay(src, svc, number, amountParam, amountDisplay, toName, dialog, frame)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun pay(
|
||||
src: BankAccount,
|
||||
svc: FahipayService,
|
||||
number: String,
|
||||
amountParam: String,
|
||||
amountDisplay: String,
|
||||
toName: String,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout,
|
||||
) {
|
||||
val app = fragment.requireActivity().application as BasedBankApp
|
||||
val session = app.fahipaySessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val deviceUuid = CredentialStore(ctx).getOrCreateFahipayDeviceUuid()
|
||||
binding.btnTransfer.isEnabled = false
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { FahipayPaymentClient().pay(session, svc.paymentPath, number, amountParam, deviceUuid) }
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
result.onSuccess {
|
||||
fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = "MVR",
|
||||
amountValue = amountDisplay,
|
||||
fromName = src.accountBriefName,
|
||||
toName = "$toName · ${svc.label}"
|
||||
) {
|
||||
fragment.clearForm()
|
||||
(fragment.activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
}.onFailure { e ->
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Payment failed"
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||
|
||||
private fun lookupCarrier(number: String) {
|
||||
fragment.resetTransferTypes()
|
||||
fragment.startLookupLoading()
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) }
|
||||
fragment.stopLookupLoading()
|
||||
if (types.isEmpty()) return@launch
|
||||
fragment.offerTransferTypes(number, types)
|
||||
}
|
||||
}
|
||||
|
||||
private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList {
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
|
||||
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
|
||||
/**
|
||||
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||
* an M-Faisa merchant QR is loaded.
|
||||
*
|
||||
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class MfaisaTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||
private val onRecipientChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
// Resolved state lives in the draft so it outlives this handler (dropped with the view).
|
||||
private val draft get() = viewModel.transferDraft
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient?
|
||||
get() = draft.mfaisaRecipient
|
||||
private set(value) { draft.mfaisaRecipient = value }
|
||||
|
||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||
var qrInfo: MfaisaQrPayClient.QrMerchant?
|
||||
get() = draft.mfaisaQrInfo
|
||||
private set(value) { draft.mfaisaQrInfo = value }
|
||||
|
||||
private var lookupInFlight = false
|
||||
|
||||
/** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */
|
||||
private var transferGuard: HomeActivity.PaymentGuard? = null
|
||||
|
||||
private fun endTransferFlow() {
|
||||
transferGuard?.end()
|
||||
transferGuard = null
|
||||
}
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||
fun isRecipientReady(): Boolean = recipient?.isMvr == true
|
||||
|
||||
/** Triggered when the user taps the search end-icon in `tilTo` (and source bank is MFAISA). */
|
||||
fun searchRecipient(rawInput: String) {
|
||||
if (lookupInFlight) return
|
||||
// Reuse the shared normalizer so "+960", "960", and embedded spaces work the same as
|
||||
// they do for MIB/BML lookup. The result is a bare 7-digit MSISDN when input was a
|
||||
// local phone number, untouched otherwise.
|
||||
val phone = AccountInputParser.normalize(rawInput)
|
||||
if (AccountInputParser.detect(phone) != AccountInputParser.InputType.PHONE) {
|
||||
binding.tilTo.error = "Enter a valid mobile number"
|
||||
return
|
||||
}
|
||||
binding.tilTo.error = null
|
||||
|
||||
val source = mfaisaSource() ?: return
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
lookupInFlight = true
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MfaisaTransferClient.forContext(ctx).searchRecipient(session, phone)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
MfaisaTransferClient.forContext(ctx).searchRecipient(fresh, phone)
|
||||
}
|
||||
}
|
||||
if (!result.isMvr) {
|
||||
// Server returned the user but only with a PayPal pocket — not supported.
|
||||
binding.tilTo.error = "This number doesn't have an MVR M-Faisa pocket"
|
||||
} else {
|
||||
recipient = result
|
||||
showResolvedRecipient(result)
|
||||
fragment.focusAmount()
|
||||
}
|
||||
} catch (_: MfaisaRecipientNotFoundException) {
|
||||
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||
} catch (e: java.io.IOException) {
|
||||
Toast.makeText(ctx, R.string.connectivity_no_internet, Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
binding.tilTo.error = e.message ?: "Lookup failed"
|
||||
} finally {
|
||||
lookupInFlight = false
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
onRecipientChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||
fun submit() {
|
||||
val source = mfaisaSource() ?: return
|
||||
val r = recipient ?: run {
|
||||
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
|
||||
val sourcePocketId = source.accountNumber // pocketId IS the accountNumber for M-Faisa accounts
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
endTransferFlow()
|
||||
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val refId = try {
|
||||
withContext(Dispatchers.IO) { initiateWithRetry(source, sourcePocketId, r, amountStr, remarks) }
|
||||
} catch (e: Exception) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
endTransferFlow()
|
||||
showError(e)
|
||||
return@launch
|
||||
}
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
// Server has now SMSed an OTP to the user's phone. Prompt them for it.
|
||||
promptForOtp(source, r, amountStr, remarks, refId, errorMsg = null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||
|
||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||
fun clearState() {
|
||||
recipient = null
|
||||
lookupInFlight = false
|
||||
}
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
fragment.setAmountLocked(false)
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||
* the amount if the QR is dynamic.
|
||||
*/
|
||||
fun lookupQrMerchant(qrCodeId: String) {
|
||||
val source = mfaisaSource() ?: run {
|
||||
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||
|
||||
// The To row stays up with a spinner until there is a merchant to swap in
|
||||
fragment.startLookupLoading()
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val merchant = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: return@withContext null
|
||||
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||
catch (_: Exception) { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
if (fragment.view == null) return@launch
|
||||
fragment.stopLookupLoading()
|
||||
if (merchant == null) {
|
||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = merchant
|
||||
|
||||
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||
if (merchant.txnAmount.isNullOrBlank()) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||
displayName = merchant.merchantName,
|
||||
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||
colorHex = "#ED1C24",
|
||||
imageHash = null,
|
||||
isProfileImage = false,
|
||||
bank = "MFAISA"
|
||||
))
|
||||
}
|
||||
|
||||
showQrMerchant(merchant)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a
|
||||
* recreated view restores it — no network involved.
|
||||
*/
|
||||
fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) {
|
||||
// Clear button is the only way to back out
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.tvToAccountName.text = merchant.merchantName
|
||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill + lock amount if the QR is dynamic
|
||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||
fragment.setAmountLocked(true)
|
||||
}
|
||||
|
||||
onRecipientChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||
*/
|
||||
fun submitQrPayment() {
|
||||
val merchant = qrInfo ?: return
|
||||
val src = currentSource() ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (src.bank != "MFAISA") {
|
||||
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
val amountValue = "%.2f".format(amount)
|
||||
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = merchant.currencyCode,
|
||||
amountValue = amountValue,
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = merchant.merchantName,
|
||||
toNumber = merchant.merchantMsisdn,
|
||||
toDetail = "Ooredoo M-Faisa merchant"
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeQrPayment(
|
||||
src: BankAccount,
|
||||
merchant: MfaisaQrPayClient.QrMerchant,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
dialog: AlertDialog
|
||||
) {
|
||||
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||
val sourceMdn = "960${initialSession.msisdn}"
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outcome = withContext(Dispatchers.IO) {
|
||||
val client = MfaisaQrPayClient()
|
||||
try {
|
||||
val refId = try {
|
||||
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||
}
|
||||
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||
try {
|
||||
client.confirmPurchase(confirmSession, refId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.confirmPurchase(fresh, refId)
|
||||
}
|
||||
Result.success(refId)
|
||||
} catch (e: Exception) {
|
||||
Result.failure<String>(e)
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
|
||||
outcome.fold(
|
||||
onSuccess = { _ ->
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountStr,
|
||||
currency = merchant.currencyCode,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = merchant.merchantName,
|
||||
toAccount = merchant.merchantMsisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
mfaisaTransactionType = "Merchant payment",
|
||||
mfaisaFromName = src.accountBriefName,
|
||||
mfaisaFromMsisdn = src.accountNumber,
|
||||
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||
mfaisaTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
dialog.dismiss()
|
||||
onTransferSuccess(receipt, null)
|
||||
},
|
||||
onFailure = { e ->
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
showError(e, fallback = "Payment failed")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||
* the source away from another bank.
|
||||
*/
|
||||
private fun mfaisaSource(): BankAccount? =
|
||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||
|
||||
/** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */
|
||||
fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) {
|
||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||
// card view, so we just populate its text fields and toggle visibility.
|
||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||
binding.tvToBankBic.text = r.msisdn
|
||||
binding.tvToAccountDetails.text = "Ooredoo M-Faisa · MVR"
|
||||
binding.tvToAccountDetails.visibility = View.VISIBLE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
if (saveRecent) RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = r.msisdn,
|
||||
displayName = r.name.ifBlank { r.msisdn },
|
||||
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||
colorHex = "#ED1C24",
|
||||
imageHash = null,
|
||||
isProfileImage = false,
|
||||
bank = "MFAISA"
|
||||
))
|
||||
}
|
||||
|
||||
/** Initiate with one automatic retry if the session has expired. */
|
||||
private fun initiateWithRetry(
|
||||
source: BankAccount,
|
||||
sourcePocketId: String,
|
||||
r: MfaisaTransferClient.Recipient,
|
||||
amountStr: String,
|
||||
remarks: String
|
||||
): String {
|
||||
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||
return try {
|
||||
MfaisaTransferClient.forContext(ctx).initiateTransfer(session, sourcePocketId, r, amountStr, remarks)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
MfaisaTransferClient.forContext(ctx).initiateTransfer(fresh, sourcePocketId, r, amountStr, remarks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmWithRetry(source: BankAccount, refId: String, otpCode: String) {
|
||||
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||
try {
|
||||
MfaisaTransferClient.forContext(ctx).confirmTransfer(session, refId, otpCode)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
MfaisaTransferClient.forContext(ctx).confirmTransfer(fresh, refId, otpCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the unified "confirm + enter OTP" dialog. Body is the standard transfer-confirm
|
||||
* view (amount + from/to blocks via [TransferFragment.buildTransferConfirmView]) plus an
|
||||
* OTP input. The Confirm button stays disabled until a 6-digit code is entered. Biometric
|
||||
* gating + invalid-OTP re-prompt + session-refresh retry are all preserved.
|
||||
*
|
||||
* The displayed "code sent to" line uses the SOURCE M-Faisa login's MSISDN (where the SMS
|
||||
* was actually sent) — the old standalone OTP dialog mistakenly showed the recipient.
|
||||
*/
|
||||
private fun promptForOtp(
|
||||
source: BankAccount,
|
||||
r: MfaisaTransferClient.Recipient,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
refId: String,
|
||||
errorMsg: String?
|
||||
) {
|
||||
val view = fragment.view ?: run { endTransferFlow(); return }
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val colorMuted = MaterialColors.getColor(
|
||||
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||
val colorOutline = MaterialColors.getColor(
|
||||
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||
|
||||
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = "MVR",
|
||||
amountValue = amountValue,
|
||||
fromName = source.accountBriefName,
|
||||
fromNumber = source.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = r.name.ifBlank { r.msisdn },
|
||||
toNumber = r.msisdn,
|
||||
toDetail = "Ooredoo M-Faisa"
|
||||
)
|
||||
|
||||
// The user's own M-Faisa MSISDN (where the SMS is sent). The session stores the bare
|
||||
// 7 digits; prefix with 960 for display.
|
||||
val userMsisdn = app.mfaisaSessionFor(source)?.msisdn
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { "960$it" }
|
||||
?: "your registered number"
|
||||
|
||||
val otpHeader = TextView(ctx).apply {
|
||||
text = "A 6-digit verification code has been sent to $userMsisdn"
|
||||
textSize = 13f
|
||||
setTextColor(colorMuted)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
val otpInput = android.widget.EditText(ctx).apply {
|
||||
hint = "Enter 6-digit code"
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
|
||||
textSize = 20f
|
||||
gravity = Gravity.CENTER
|
||||
letterSpacing = 0.3f
|
||||
}
|
||||
val errorView = errorMsg?.let {
|
||||
TextView(ctx).apply {
|
||||
text = it
|
||||
textSize = 13f
|
||||
setTextColor(Color.RED)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
}
|
||||
val divider = View(ctx).apply {
|
||||
setBackgroundColor(colorOutline)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).apply {
|
||||
topMargin = (8 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val otpSection = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding((20 * dp).toInt(), (12 * dp).toInt(), (20 * dp).toInt(), (4 * dp).toInt())
|
||||
addView(otpHeader, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||
addView(otpInput, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = (8 * dp).toInt()
|
||||
})
|
||||
if (errorView != null) {
|
||||
addView(errorView, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = (8 * dp).toInt()
|
||||
})
|
||||
}
|
||||
}
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(confirmView)
|
||||
addView(divider)
|
||||
addView(otpSection)
|
||||
}
|
||||
|
||||
// Hide any previously-open keyboard so the OTP field can claim focus cleanly.
|
||||
val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.transfer)
|
||||
.setView(container)
|
||||
.setPositiveButton(R.string.transfer_confirm, null)
|
||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
endTransferFlow()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
|
||||
val confirmBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
confirmBtn.isEnabled = false
|
||||
otpInput.addTextChangedListener { text ->
|
||||
confirmBtn.isEnabled = (text?.length ?: 0) == 6
|
||||
}
|
||||
|
||||
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
val canAuth = BiometricManager.from(ctx)
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
val runConfirm: () -> Unit = {
|
||||
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||
dialog.dismiss()
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
endTransferFlow()
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountValue,
|
||||
currency = "MVR",
|
||||
fromLabel = source.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = r.name.ifBlank { r.msisdn },
|
||||
toAccount = r.msisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
mfaisaTransactionType = "Transfer to mobile",
|
||||
mfaisaFromName = source.accountBriefName,
|
||||
mfaisaFromMsisdn = source.accountNumber,
|
||||
mfaisaToMsisdn = r.msisdn,
|
||||
mfaisaTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
onTransferSuccess(receipt, null)
|
||||
} catch (e: MfaisaInvalidOtpException) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
// Server kept the referenceId alive — re-prompt without restarting initiate.
|
||||
promptForOtp(source, r, amountStr, remarks, refId, e.message)
|
||||
} catch (e: Exception) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
endTransferFlow()
|
||||
showError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmBtn.setOnClickListener {
|
||||
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||
if (otp.length != 6) { otpInput.error = "Enter 6 digits"; return@setOnClickListener }
|
||||
if (biometricTransferConfirm && canAuth) {
|
||||
val prompt = BiometricPrompt(fragment, ContextCompat.getMainExecutor(ctx),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
runConfirm()
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||
Toast.makeText(ctx, errString, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||
})
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(ctx.getString(R.string.biometric_transfer_title))
|
||||
.setSubtitle("MVR $amountValue → ${r.name.ifBlank { r.msisdn }}")
|
||||
.setNegativeButtonText(ctx.getString(android.R.string.cancel))
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
runConfirm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> fallback
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.api.mib.MibLookupException
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
/**
|
||||
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
|
||||
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
|
||||
* avatar API that every bank's "To" card borrows.
|
||||
*
|
||||
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
|
||||
* paints nothing — every method is either a session accessor or a blocking API call meant for
|
||||
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
|
||||
* the view updates, so this handler can outlive a view recreation.
|
||||
*/
|
||||
class MibTransferHandler(
|
||||
private val fragment: Fragment,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** The selected source's MIB session, falling back to any logged-in MIB session. */
|
||||
val session: MibSession?
|
||||
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
|
||||
|
||||
/** Any logged-in MIB session, ignoring which source is selected. */
|
||||
val anySession: MibSession? get() = app.anyMibSession()
|
||||
|
||||
// ─── Destination lookup ──────────────────────────────────────────────────
|
||||
|
||||
/** Either a resolved destination or the message to show the user — never both. */
|
||||
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
|
||||
|
||||
/**
|
||||
* IPS lookup for a destination account/alias. Blocking — call from IO.
|
||||
* A missing MIB session is reported as "account not found", same as a failed lookup.
|
||||
*/
|
||||
fun lookupDestination(accountNumber: String): LookupOutcome {
|
||||
val sess = session ?: return LookupOutcome(null, notFound())
|
||||
return try {
|
||||
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
|
||||
} catch (e: MibLookupException) {
|
||||
LookupOutcome(null, e.message)
|
||||
} catch (_: Exception) {
|
||||
LookupOutcome(null, notFound())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint
|
||||
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
|
||||
*/
|
||||
fun lookupCurrency(accountNumber: String): String {
|
||||
val sess = session ?: return ""
|
||||
return try { MibTransferClient().lookup(sess, accountNumber).currency }
|
||||
catch (_: Exception) { "" }
|
||||
}
|
||||
|
||||
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
|
||||
|
||||
// ─── Avatars ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
|
||||
* Blocking — call from IO. Returns null on any failure, including a missing image.
|
||||
*
|
||||
* The session is passed in rather than read from [session] because callers differ: the "To"
|
||||
* card uses the selected source's session, the account dropdown uses [anySession].
|
||||
*/
|
||||
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
|
||||
val base64 = if (isProfile) {
|
||||
app.anyMibFlow()?.fetchProfileImage(session, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(session, hash)
|
||||
}
|
||||
if (base64 == null) null else {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
// ─── Transfer ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
|
||||
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
|
||||
* "CONNECTIVITY" when the network was unreachable.
|
||||
*/
|
||||
fun doTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destName: String,
|
||||
destDisplay: String,
|
||||
amount: String,
|
||||
remarks: String,
|
||||
bankName: String
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||
val loginId = src.loginTag.removePrefix("mib_")
|
||||
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: return Triple(false, "OTP unavailable", null)
|
||||
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
||||
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||
val bankNo = if (isDestMib) 2 else 3
|
||||
val toBank = when {
|
||||
isDestMib -> "MIB"
|
||||
else -> when (bankName.uppercase()) {
|
||||
"MALBMVMV" -> "BML"
|
||||
"MADVMVMV" -> "MIB"
|
||||
else -> bankName.ifBlank { "LOCAL" }
|
||||
}
|
||||
}
|
||||
val isOwnAccount = isDestMib && app.mibAccounts.any {
|
||||
it.accountNumber == destAccount && it.loginTag == src.loginTag &&
|
||||
(src.profileId.isBlank() || it.profileId == src.profileId)
|
||||
}
|
||||
val transactionType = when {
|
||||
isOwnAccount -> "Own Transfer"
|
||||
isDestMib -> "MIB Transfer"
|
||||
else -> "Quick Transfer"
|
||||
}
|
||||
return try {
|
||||
// Switch to the profile that owns the source account
|
||||
if (src.profileId.isNotBlank()) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||
}
|
||||
val result = MibTransferClient().transfer(
|
||||
session = sess,
|
||||
fromAccount = src.accountNumber,
|
||||
toAccount = destAccount,
|
||||
amount = amount,
|
||||
currencyCode = currencyCode,
|
||||
benefName = destName.ifBlank { "Recipient" },
|
||||
bankNo = bankNo,
|
||||
purpose = remarks,
|
||||
otp = otp
|
||||
)
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MIB",
|
||||
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||
currency = currency,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#FE860E",
|
||||
fromProfileImageHash = src.profileImageHash,
|
||||
toLabel = destDisplay.ifBlank { destName },
|
||||
toAccount = destAccount,
|
||||
toBank = toBank,
|
||||
remarks = remarks,
|
||||
mibReferenceNo = result.trxId,
|
||||
mibTransactionDate = result.date,
|
||||
mibFromProfileName = src.profileName,
|
||||
mibTransactionType = transactionType
|
||||
)
|
||||
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.content.Context
|
||||
import android.text.InputType
|
||||
import androidx.annotation.DrawableRes
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
|
||||
/**
|
||||
* A carrier service a phone number can be paid with (reload, Raastas, bill pay), whichever
|
||||
* route pays it — the Fahipay wallet ([FahipayService]) or a verified BML card
|
||||
* ([CardPayoutService]). Each route has its own limits, so each has its own constants.
|
||||
*/
|
||||
interface PayoutService {
|
||||
/** Names it in the "Transfer Type" picker and the recipient card. */
|
||||
val label: String
|
||||
/** Names it in the confirm dialog's "To" block, e.g. "Ooredoo · Raastas". */
|
||||
val destinationLabel: String
|
||||
@get:DrawableRes val iconRes: Int
|
||||
/** Smallest amount the service accepts, in MVR. */
|
||||
val minAmount: Int
|
||||
/** Largest amount the service accepts, in MVR, or null for no limit. */
|
||||
val maxAmount: Int?
|
||||
/** Whether the amount may have a fractional part (up to 2 decimal places). */
|
||||
val decimalsAllowed: Boolean
|
||||
/** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
|
||||
val gstPercent: Int?
|
||||
/**
|
||||
* False (the usual): GST comes out of the amount, so the number is credited less
|
||||
* ([creditedAfterGst]). True: the number is credited the whole amount and GST is charged on
|
||||
* top of it ([chargedWithGst]).
|
||||
*/
|
||||
val gstAdded: Boolean get() = false
|
||||
|
||||
/**
|
||||
* What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded
|
||||
* down. Null when the service charges no GST, or adds it on top.
|
||||
*/
|
||||
fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
|
||||
val gst = gstPercent ?: return null
|
||||
if (gstAdded) return null
|
||||
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
|
||||
}
|
||||
|
||||
/**
|
||||
* What is paid for [amount]: amount + round2(amount × rate) when GST is added on top,
|
||||
* otherwise [amount] itself.
|
||||
*/
|
||||
fun chargedWithGst(amount: BigDecimal): BigDecimal {
|
||||
val gst = gstPercent?.takeIf { gstAdded } ?: return amount
|
||||
return amount + (amount * BigDecimal(gst).movePointLeft(2)).setScale(2, RoundingMode.HALF_UP)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the Transfer screen's amount and reference fields in step with a picked
|
||||
* [PayoutService]: the amount error, the GST note under the amount, the keypad, and the
|
||||
* reference box disabled (none of the payouts take one). Shared by the Fahipay and card routes.
|
||||
*/
|
||||
class PayoutAmountField(
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val context: () -> Context,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Why the typed amount can't be sent with [svc], or null when it can (or the field is
|
||||
* empty). Checks the service's minimum, maximum and whether it takes decimals.
|
||||
*/
|
||||
fun problem(svc: PayoutService): String? {
|
||||
val ctx = context()
|
||||
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||
if (text.isEmpty()) return null
|
||||
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
|
||||
val fraction = amount.stripTrailingZeros().scale()
|
||||
return when {
|
||||
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
|
||||
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
|
||||
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
|
||||
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount!!) ->
|
||||
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** Idempotent — the fragment calls it on every form change. */
|
||||
fun sync(svc: PayoutService) {
|
||||
// Whole-number services get a keypad without a decimal point. Only set on change:
|
||||
// setting inputType restarts the keyboard, and this runs on every keystroke.
|
||||
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
|
||||
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
val problem = problem(svc)
|
||||
binding.tilAmount.error = problem
|
||||
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
|
||||
}
|
||||
|
||||
/**
|
||||
* For services that charge GST: what the recipient is credited once it comes out, or what
|
||||
* is paid once it's added on top.
|
||||
*/
|
||||
fun gstNote(svc: PayoutService): String? {
|
||||
val gst = svc.gstPercent ?: return null
|
||||
val ctx = context()
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||
if (svc.gstAdded) {
|
||||
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_gst_added_hint, gst)
|
||||
return ctx.getString(R.string.transfer_gst_added_pay, "%,.2f".format(svc.chargedWithGst(amount)), gst)
|
||||
}
|
||||
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
|
||||
val credited = svc.creditedAfterGst(amount) ?: return null
|
||||
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
|
||||
}
|
||||
|
||||
/** Gives back the amount and reference fields once no service is picked. */
|
||||
fun reset() {
|
||||
binding.tilAmount.error = null
|
||||
binding.tilAmount.helperText = null
|
||||
binding.etAmount.inputType = DECIMAL_INPUT
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
|
||||
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
/**
|
||||
* Everything the Transfer screen has filled in or resolved so far: source, recipient, form
|
||||
* text and any loaded merchant QR.
|
||||
*
|
||||
* Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives
|
||||
* both the view (switching tabs) and the fragment instance (a theme or language change
|
||||
* recreates the activity) — the screen is repainted from here instead of re-running lookups.
|
||||
* A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh
|
||||
* draft.
|
||||
*/
|
||||
class TransferDraft {
|
||||
var selectedAccount: BankAccount? = null
|
||||
|
||||
// Resolved recipient — set after a successful lookup or prefill
|
||||
var resolvedAccountNumber = ""
|
||||
var resolvedRecipientName = ""
|
||||
var resolvedBankName = ""
|
||||
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
||||
var loadedToPhoto: Bitmap? = null
|
||||
var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||
var resolvedToOwnAccount: BankAccount? = null
|
||||
var toSubtitle = ""
|
||||
var toColorHex = "#607D8B"
|
||||
var toImageHash: String? = null
|
||||
|
||||
// Form text, captured when the view goes away
|
||||
var amount = ""
|
||||
var remarks = ""
|
||||
var toText = ""
|
||||
|
||||
// BML card-only merchant payment (merchant without BML Pay, paid by verified card + 3-D Secure)
|
||||
var bmlCardMerchant: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage? = null
|
||||
|
||||
// BML merchant QR
|
||||
var bmlQrInfo: BmlQrPayInfo? = null
|
||||
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
|
||||
var bmlGatewayQr = false
|
||||
/** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */
|
||||
var pendingBmlQrTarget: String? = null
|
||||
|
||||
// M-Faisa
|
||||
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
|
||||
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||
|
||||
// Transfer type — options the "To" lookup offered for [transferTypeNumber], and the pick
|
||||
var transferTypes: List<TransferType> = emptyList()
|
||||
var transferTypeNumber = ""
|
||||
var transferType: TransferType? = null
|
||||
|
||||
// Fahipay
|
||||
var fahipayService: FahipayService? = null
|
||||
|
||||
// Carrier service paid by verified BML card
|
||||
var cardPayoutService: CardPayoutService? = null
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
/**
|
||||
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
|
||||
* the "To" field. Picking one also decides the source account — see [worksFrom].
|
||||
*
|
||||
* Add new kinds as subclasses; the exhaustive `when`s over this type point at every site that
|
||||
* needs updating.
|
||||
*/
|
||||
sealed interface TransferType {
|
||||
val label: String
|
||||
/** Second line in the picker: who gets paid, and how. */
|
||||
val subtitle: String
|
||||
@get:DrawableRes val iconRes: Int
|
||||
/** Small logo on the icon's bottom corner — the wallet that pays it — or null for none. */
|
||||
@get:DrawableRes val badgeRes: Int? get() = null
|
||||
|
||||
/** Whether [account] can be the source for this transfer type. */
|
||||
fun worksFrom(account: BankAccount): Boolean
|
||||
|
||||
/** A bank transfer to the account the Favara ID (phone number) resolved to. */
|
||||
data class Favara(val info: MibIpsAccountInfo) : TransferType {
|
||||
override val label get() = "Favara Transfer"
|
||||
override val subtitle get() = info.accountName
|
||||
override val iconRes get() = R.drawable.favara_logo
|
||||
override fun worksFrom(account: BankAccount) = account.bank == "MIB" || account.bank == "BML"
|
||||
}
|
||||
|
||||
/** A Fahipay payout (reload, Raastas, bill pay) to the phone number. */
|
||||
data class Fahipay(val service: FahipayService, val ownerName: String?) : TransferType {
|
||||
override val label get() = service.label
|
||||
override val subtitle get() = listOfNotNull(ownerName, "via Fahipay").joinToString(" · ")
|
||||
override val iconRes get() = service.iconRes
|
||||
override val badgeRes get() = R.drawable.fahipay_logo
|
||||
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
|
||||
}
|
||||
|
||||
/**
|
||||
* A carrier service (reload, …) paid by verified BML card through the carrier's own BML
|
||||
* merchant gateway. [cards] are the account numbers of the cards that can pay it — verified,
|
||||
* with an OTP seed for the 3-D Secure step — worked out when the lookup ran.
|
||||
*/
|
||||
data class Card(val service: CardPayoutService, val ownerName: String?, val cards: Set<String>) : TransferType {
|
||||
override val label get() = service.label
|
||||
override val subtitle get() = listOfNotNull(ownerName, "by BML card").joinToString(" · ")
|
||||
override val iconRes get() = service.iconRes
|
||||
override val badgeRes get() = R.drawable.bml_logo_vector
|
||||
override fun worksFrom(account: BankAccount) = account.bank == "BML" && account.accountNumber in cards
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ class BankSelectionFragment : Fragment() {
|
||||
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
|
||||
findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args)
|
||||
}
|
||||
binding.cardOoredoo.setOnClickListener {
|
||||
val args = android.os.Bundle().apply { putString("bankType", "OOREDOO") }
|
||||
findNavController().navigate(R.id.action_bankSelection_to_credentials_ooredoo, args)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -30,6 +30,12 @@ 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.FahipaySession
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaAccountClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidPinException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaKycRequiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaNotRegisteredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaWalletNotReadyException
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
@@ -102,6 +108,19 @@ class CredentialsFragment : Fragment() {
|
||||
binding.etOtpSeed.isEnabled = false
|
||||
binding.etOtpSeed.isFocusable = false
|
||||
}
|
||||
"OOREDOO" -> {
|
||||
binding.ivBankLogo.setImageResource(R.drawable.ooredoo_logo_long)
|
||||
binding.tvSignInDesc.setText(R.string.ooredoo_sign_in_desc)
|
||||
binding.tilUsername.hint = getString(R.string.ooredoo_phone)
|
||||
binding.etUsername.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||
binding.etPassword.inputType =
|
||||
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
binding.etPassword.filters = arrayOf<android.text.InputFilter>(android.text.InputFilter.LengthFilter(4))
|
||||
binding.tilPassword.hint = getString(R.string.ooredoo_pin)
|
||||
binding.rowOtpSeed.visibility = android.view.View.GONE
|
||||
binding.etOtpSeed.isEnabled = false
|
||||
binding.etOtpSeed.isFocusable = false
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnLogin.isEnabled = false
|
||||
@@ -110,8 +129,8 @@ class CredentialsFragment : Fragment() {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
binding.cardOtp.setOnClickListener {
|
||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||
binding.cardOtp.root.setOnClickListener {
|
||||
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
|
||||
if (code.isNotEmpty()) {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
@@ -133,7 +152,7 @@ class CredentialsFragment : Fragment() {
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
if (bankType != "FAHIPAY") {
|
||||
if (bankType != "FAHIPAY" && bankType != "OOREDOO") {
|
||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
updateOtpDisplay()
|
||||
@@ -147,7 +166,7 @@ class CredentialsFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (bankType != "FAHIPAY") otpHandler.post(otpRunnable)
|
||||
if (bankType != "FAHIPAY" && bankType != "OOREDOO") otpHandler.post(otpRunnable)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -170,6 +189,7 @@ class CredentialsFragment : Fragment() {
|
||||
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||
binding.btnLogin.isEnabled = when (bankType) {
|
||||
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||
"OOREDOO" -> username.isNotEmpty() && password.length == 4
|
||||
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||
}
|
||||
}
|
||||
@@ -178,12 +198,12 @@ class CredentialsFragment : Fragment() {
|
||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||
val seed = resolveOtpSeed(otpSeedRaw)
|
||||
if (seed.isEmpty()) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
val password = binding.etPassword.text.toString()
|
||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -191,12 +211,13 @@ class CredentialsFragment : Fragment() {
|
||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||
val remaining = 30 - secondsInPeriod
|
||||
|
||||
binding.tvOtpCode.text = otp
|
||||
binding.otpTimer.max = 30
|
||||
binding.otpTimer.progress = remaining
|
||||
binding.cardOtp.visibility = View.VISIBLE
|
||||
binding.cardOtp.tvOtpCode.text = otp
|
||||
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||
binding.cardOtp.otpTimer.max = 30
|
||||
binding.cardOtp.otpTimer.progress = remaining
|
||||
binding.cardOtp.root.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +225,7 @@ class CredentialsFragment : Fragment() {
|
||||
when (bankType) {
|
||||
"BML" -> { attemptBmlLogin(); return }
|
||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||
"OOREDOO" -> { attemptMfaisaLogin(); return }
|
||||
}
|
||||
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
@@ -411,6 +433,97 @@ class CredentialsFragment : Fragment() {
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun attemptMfaisaLogin() {
|
||||
val msisdn = binding.etUsername.text.toString().trim()
|
||||
val pin = binding.etPassword.text.toString()
|
||||
|
||||
if (msisdn.isEmpty() || pin.length != 4) {
|
||||
binding.tvError.text = "Please enter your phone number and 4-digit mPIN"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
binding.tvError.visibility = View.GONE
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.etUsername.isEnabled = false
|
||||
binding.etPassword.isEnabled = false
|
||||
|
||||
val store = CredentialStore(requireContext())
|
||||
val flow = MfaisaLoginFlow(requireContext())
|
||||
val loginTag = "mfaisa_$msisdn"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { flow.fetchSubscriber(msisdn) }
|
||||
val result = withContext(Dispatchers.IO) { flow.doMobileLogin(msisdn, pin) }
|
||||
val accounts = MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||
|
||||
store.saveMfaisaCredentials(msisdn, msisdn, pin)
|
||||
store.saveMfaisaUserProfile(
|
||||
msisdn,
|
||||
CredentialStore.MfaisaUserProfile(
|
||||
name = result.profile.name,
|
||||
email = result.profile.email,
|
||||
mdnId = result.profile.mdnId,
|
||||
subscriberId = result.profile.subscriberId,
|
||||
walletId = result.profile.walletId,
|
||||
roleId = result.profile.roleId,
|
||||
offerId = result.profile.offerId
|
||||
)
|
||||
)
|
||||
AccountCache.saveMfaisa(requireContext(), msisdn, accounts)
|
||||
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.mfaisaSessions[msisdn] = result.session
|
||||
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != loginTag } + accounts
|
||||
app.accounts = app.accounts.filter { it.loginTag != loginTag } + accounts
|
||||
app.isUnlocked = true
|
||||
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
} catch (e: MfaisaNotRegisteredException) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("User not registered")
|
||||
.setMessage("Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree.")
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
binding.tvError.visibility = View.GONE
|
||||
} catch (e: MfaisaKycRequiredException) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("KYC incomplete")
|
||||
.setMessage("Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree.")
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
binding.tvError.visibility = View.GONE
|
||||
} catch (e: MfaisaWalletNotReadyException) {
|
||||
binding.tvError.text = e.message
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
} catch (e: MfaisaInvalidPinException) {
|
||||
val message = if (e.lastAttempt)
|
||||
"${e.message}\n\nOne more wrong mPIN will lock your account."
|
||||
else
|
||||
e.message
|
||||
binding.tvError.text = message ?: "Incorrect mPIN"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
// Re-enable PIN input only — leave phone number locked
|
||||
binding.etPassword.isEnabled = true
|
||||
binding.etPassword.setText("")
|
||||
binding.etPassword.requestFocus()
|
||||
} catch (e: Exception) {
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
binding.etUsername.isEnabled = true
|
||||
binding.etPassword.isEnabled = true
|
||||
} finally {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
// After PIN error, keep phone disabled; for any other resolution above we already restored as needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptFahipayLogin() {
|
||||
if (fahipayAwaitingTotp) {
|
||||
submitFahipayTotp()
|
||||
|
||||
@@ -11,6 +11,7 @@ object AccountCache {
|
||||
private const val KEY_MIB = "mib_accounts"
|
||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||
private fun mfaisaKey(loginId: String) = "mfaisa_accounts_$loginId"
|
||||
|
||||
fun save(context: Context, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
@@ -150,6 +151,62 @@ object AccountCache {
|
||||
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadFahipay(context, it) }
|
||||
|
||||
fun saveMfaisa(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("profileName", acc.profileName)
|
||||
put("profileType", acc.profileType)
|
||||
put("accountNumber", acc.accountNumber)
|
||||
put("accountBriefName", acc.accountBriefName)
|
||||
put("currencyName", acc.currencyName)
|
||||
put("accountTypeName", acc.accountTypeName)
|
||||
put("availableBalance", acc.availableBalance)
|
||||
put("currentBalance", acc.currentBalance)
|
||||
put("blockedAmount", acc.blockedAmount)
|
||||
put("mvrBalance", acc.mvrBalance)
|
||||
put("statusDesc", acc.statusDesc)
|
||||
put("loginTag", acc.loginTag)
|
||||
put("profileId", acc.profileId)
|
||||
put("internalId", acc.internalId)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(mfaisaKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadMfaisa(context: Context, loginId: String): List<BankAccount> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(mfaisaKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
BankAccount(
|
||||
bank = "MFAISA",
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
accountBriefName = o.optString("accountBriefName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
availableBalance = o.optString("availableBalance"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
profileImageHash = null,
|
||||
loginTag = o.optString("loginTag"),
|
||||
profileId = o.optString("profileId", ""),
|
||||
internalId = o.optString("internalId", "")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadMfaisa(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadMfaisa(context, it) }
|
||||
|
||||
fun clear(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ package sh.sar.basedbank.util
|
||||
data class AccountHistoryDisplay(
|
||||
val name: String,
|
||||
val number: String,
|
||||
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
|
||||
val bankPill: String?, // "BML", "FP", "MIB" — bank label shown on the 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"
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.mfaisaapi.MfaisaHistoryParser
|
||||
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||
|
||||
object AccountHistoryParser {
|
||||
@@ -11,6 +12,7 @@ object AccountHistoryParser {
|
||||
"BML" -> BmlHistoryParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||
"MIB" -> MibHistoryParser.displayData(account)
|
||||
"MFAISA" -> MfaisaHistoryParser.displayData(account)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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.mfaisaapi.MfaisaAccountParser
|
||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||
|
||||
object AccountListParser {
|
||||
@@ -11,6 +12,7 @@ object AccountListParser {
|
||||
"BML" -> BmlDashboardParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||
"MIB" -> MibAccountParser.displayData(account)
|
||||
"MFAISA" -> MfaisaAccountParser.displayData(account)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ data class ContactDisplay(
|
||||
val network: TransferNetwork,
|
||||
val bankColor: String,
|
||||
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
||||
val bankName: String?, // shown in the contact details sheet; null = hide row
|
||||
val currency: String?, // shown in the contact details sheet; 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
|
||||
|
||||
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class CredentialStore(context: Context) {
|
||||
|
||||
companion object {
|
||||
/** Same format as BankAccount.loginTag / MibCard.loginTag, e.g. "bml_<loginId>". */
|
||||
fun loginKey(bank: String, loginId: String) = "${bank}_$loginId"
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||
private val keyAlias = "basedbank_credential_key"
|
||||
private val transformation = "AES/GCM/NoPadding"
|
||||
@@ -21,6 +26,38 @@ class CredentialStore(context: Context) {
|
||||
data class MibCredentials(val username: String, val passwordHash: 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 MfaisaCredentials(val msisdn: String, val pin: String)
|
||||
|
||||
// ── Cross-bank login order (keys are login tags, see loginKey) ──────────
|
||||
|
||||
/** User-chosen order of all logins; logins not yet ordered (e.g. newly added) follow, bank by bank. */
|
||||
fun getLoginOrder(): List<String> {
|
||||
val all = getMibLoginIds().map { loginKey("mib", it) } +
|
||||
getBmlLoginIds().map { loginKey("bml", it) } +
|
||||
getFahipayLoginIds().map { loginKey("fahipay", it) } +
|
||||
getMfaisaLoginIds().map { loginKey("mfaisa", it) }
|
||||
val saved = try {
|
||||
val arr = org.json.JSONArray(prefs.getString("login_order", null) ?: "[]")
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
return saved.filter { it in all } + all.filter { it !in saved }
|
||||
}
|
||||
|
||||
/** Position of a login tag in [getLoginOrder]; unknown tags sort last. */
|
||||
fun loginRank(): (String) -> Int {
|
||||
val order = getLoginOrder()
|
||||
return { tag -> order.indexOf(tag).let { if (it < 0) Int.MAX_VALUE else it } }
|
||||
}
|
||||
|
||||
/** Saves the combined order and keeps each bank's own login list in the same relative order. */
|
||||
fun setLoginOrder(keys: List<String>) {
|
||||
prefs.edit().putString("login_order", org.json.JSONArray(keys).toString()).apply()
|
||||
fun idsFor(bank: String) = keys.filter { it.startsWith("${bank}_") }.map { it.removePrefix("${bank}_") }
|
||||
setMibLoginIds(idsFor("mib"))
|
||||
setBmlLoginIds(idsFor("bml"))
|
||||
setFahipayLoginIds(idsFor("fahipay"))
|
||||
setMfaisaLoginIds(idsFor("mfaisa"))
|
||||
}
|
||||
|
||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||
|
||||
@@ -35,6 +72,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||
|
||||
fun setMibLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMibLoginIds().toSet()) return
|
||||
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMibLoginId(loginId: String) {
|
||||
val ids = getMibLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -58,6 +100,11 @@ class CredentialStore(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||
fun updateMibOtpSeed(loginId: String, otpSeed: String) {
|
||||
prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||
}
|
||||
|
||||
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||
@@ -159,6 +206,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||
|
||||
fun setBmlLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getBmlLoginIds().toSet()) return
|
||||
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addBmlLoginId(loginId: String) {
|
||||
val ids = getBmlLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -183,6 +235,11 @@ class CredentialStore(context: Context) {
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
|
||||
fun updateBmlOtpSeed(loginId: String, otpSeed: String) {
|
||||
prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
|
||||
}
|
||||
|
||||
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||
@@ -315,6 +372,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||
|
||||
fun setFahipayLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getFahipayLoginIds().toSet()) return
|
||||
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addFahipayLoginId(loginId: String) {
|
||||
val ids = getFahipayLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -460,6 +522,115 @@ class CredentialStore(context: Context) {
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
// ── M-Faisa login credentials (multi-login, keyed by loginId = msisdn) ───
|
||||
|
||||
fun getMfaisaLoginIds(): List<String> {
|
||||
val json = prefs.getString("mfaisa_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 hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||
|
||||
fun setMfaisaLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMfaisaLoginIds().toSet()) return
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
ids.add(loginId)
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (ids.remove(loginId))
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
|
||||
fun saveMfaisaCredentials(loginId: String, msisdn: String, pin: String) {
|
||||
addMfaisaLoginId(loginId)
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit()
|
||||
.putString("mfaisa_${loginId}_enc_msisdn", encrypt(msisdn, key))
|
||||
.putString("mfaisa_${loginId}_enc_pin", encrypt(pin, key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadMfaisaCredentials(loginId: String): MfaisaCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encMsisdn = prefs.getString("mfaisa_${loginId}_enc_msisdn", null) ?: return null
|
||||
val encPin = prefs.getString("mfaisa_${loginId}_enc_pin", null) ?: return null
|
||||
return try {
|
||||
MfaisaCredentials(decrypt(encMsisdn, key), decrypt(encPin, key))
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun clearMfaisaCredentials(loginId: String) {
|
||||
removeMfaisaLoginId(loginId)
|
||||
prefs.edit()
|
||||
.remove("mfaisa_${loginId}_enc_msisdn")
|
||||
.remove("mfaisa_${loginId}_enc_pin")
|
||||
.remove("mfaisa_${loginId}_enc_profile")
|
||||
.remove("mfaisa_${loginId}_hidden_pockets")
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Pocket-level hide flags (parallels [getHiddenBmlProfileIds]). Keyed by pocket ID. */
|
||||
fun getHiddenMfaisaPocketIds(loginId: String): Set<String> =
|
||||
prefs.getStringSet("mfaisa_${loginId}_hidden_pockets", emptySet()) ?: emptySet()
|
||||
|
||||
fun setHiddenMfaisaPocketIds(loginId: String, ids: Set<String>) =
|
||||
prefs.edit().putStringSet("mfaisa_${loginId}_hidden_pockets", ids).apply()
|
||||
|
||||
// ── M-Faisa user profile (per loginId) ────────────────────────────────────
|
||||
|
||||
data class MfaisaUserProfile(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val mdnId: String,
|
||||
val subscriberId: String,
|
||||
val walletId: String,
|
||||
val roleId: String,
|
||||
val offerId: String
|
||||
)
|
||||
|
||||
fun saveMfaisaUserProfile(loginId: String, p: MfaisaUserProfile) {
|
||||
val json = org.json.JSONObject().apply {
|
||||
put("name", p.name)
|
||||
put("email", p.email)
|
||||
put("mdnId", p.mdnId)
|
||||
put("subscriberId", p.subscriberId)
|
||||
put("walletId", p.walletId)
|
||||
put("roleId", p.roleId)
|
||||
put("offerId", p.offerId)
|
||||
}.toString()
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit().putString("mfaisa_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||
}
|
||||
|
||||
fun loadMfaisaUserProfile(loginId: String): MfaisaUserProfile? {
|
||||
val key = getOrCreateKey()
|
||||
val enc = prefs.getString("mfaisa_${loginId}_enc_profile", null) ?: return null
|
||||
return try {
|
||||
val o = org.json.JSONObject(decrypt(enc, key))
|
||||
MfaisaUserProfile(
|
||||
name = o.optString("name"),
|
||||
email = o.optString("email"),
|
||||
mdnId = o.optString("mdnId"),
|
||||
subscriberId = o.optString("subscriberId"),
|
||||
walletId = o.optString("walletId"),
|
||||
roleId = o.optString("roleId"),
|
||||
offerId = o.optString("offerId")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -659,6 +830,15 @@ class CredentialStore(context: Context) {
|
||||
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
||||
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
||||
|
||||
// ── Per-account visibility (account numbers are globally unique) ─────────
|
||||
|
||||
/** Returns the set of account numbers the user has chosen to hide, across all logins. */
|
||||
fun getHiddenAccountNumbers(): Set<String> =
|
||||
prefs.getStringSet("hidden_account_numbers", emptySet()) ?: emptySet()
|
||||
|
||||
fun setHiddenAccountNumbers(accountNumbers: Set<String>) =
|
||||
prefs.edit().putStringSet("hidden_account_numbers", accountNumbers).apply()
|
||||
|
||||
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||
|
||||
private fun getOrCreateKey(): SecretKey {
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.mfaisa.MfaisaHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
@@ -24,6 +25,8 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
||||
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||
private val isMfaisa get() = account.bank == "MFAISA"
|
||||
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||
|
||||
// MIB pagination
|
||||
private var mibNextStart = 1
|
||||
@@ -35,17 +38,39 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
|
||||
// BML card pagination (month-based)
|
||||
private var cardMonthOffset = 0
|
||||
private var pendingCardOutstanding: List<BankTransaction>? = null
|
||||
private var pendingCardUnbilled: List<BankTransaction>? = null
|
||||
|
||||
/**
|
||||
* Returns and clears the card outstanding + unbilled lists captured on the first card
|
||||
* fetch. Each list is only ever returned once.
|
||||
*/
|
||||
fun takeCardPendingSections(): Pair<List<BankTransaction>, List<BankTransaction>>? {
|
||||
val o = pendingCardOutstanding
|
||||
val u = pendingCardUnbilled
|
||||
if (o == null && u == null) return null
|
||||
pendingCardOutstanding = null
|
||||
pendingCardUnbilled = null
|
||||
return Pair(o ?: emptyList(), u ?: emptyList())
|
||||
}
|
||||
|
||||
// Fahipay pagination
|
||||
private var fahipayNextStart = 0
|
||||
private var fahipayTotal = -1
|
||||
|
||||
// M-Faisa pagination — the server doesn't return a "total" field, so we infer "more pages exist"
|
||||
// from whether the last page was full-sized.
|
||||
private var mfaisaNextPage = 1
|
||||
private var mfaisaHasMore = true
|
||||
|
||||
fun hasMore(): Boolean = when {
|
||||
isBmlLoan -> false
|
||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard -> cardMonthOffset < 3
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
isBmlLoan -> false
|
||||
isMfaisaPaypal -> false // PayPal pockets have no known history endpoint
|
||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard -> cardMonthOffset < 3
|
||||
isMfaisa -> mfaisaHasMore
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
}
|
||||
|
||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||
@@ -53,9 +78,36 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||
isMfaisa -> withContext(Dispatchers.IO) { fetchMfaisa(app) }
|
||||
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||
}
|
||||
|
||||
private fun fetchMfaisa(app: BasedBankApp): List<BankTransaction> {
|
||||
val loginId = account.loginTag.removePrefix("mfaisa_")
|
||||
var session = app.mfaisaSessionFor(account) ?: return emptyList()
|
||||
val page = try {
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
pageNo = mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
session = app.refreshMfaisaSession(loginId) ?: return emptyList()
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
pageNo = mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
}
|
||||
mfaisaHasMore = page.hasMore
|
||||
mfaisaNextPage++
|
||||
return page.transactions
|
||||
}
|
||||
|
||||
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||
@@ -90,16 +142,22 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
val isFirstFetch = cardMonthOffset == 0
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
cardMonthOffset++
|
||||
return BmlHistoryClient().fetchCardHistory(
|
||||
val result = BmlHistoryClient().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
if (isFirstFetch) {
|
||||
pendingCardOutstanding = result.outstanding
|
||||
pendingCardUnbilled = result.unbilled
|
||||
}
|
||||
return result.statement
|
||||
}
|
||||
|
||||
private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> {
|
||||
|
||||
@@ -13,6 +13,27 @@ object OtpauthParser {
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw
|
||||
* secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed.
|
||||
*/
|
||||
fun resolveSecret(input: String): String? {
|
||||
val raw = input.trim()
|
||||
val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw
|
||||
val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase()
|
||||
if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null
|
||||
// Too short to be a real seed, e.g. a pasted 6-digit OTP code
|
||||
if (clean.length < 8) return null
|
||||
return clean
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the shortest otpauth://totp link authenticator apps accept: just a label and the
|
||||
* secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out.
|
||||
*/
|
||||
fun buildUri(label: String, secret: String): String =
|
||||
"otpauth://totp/" + Uri.encode(label) + "?secret=$secret"
|
||||
|
||||
private fun parseStandard(raw: String): OtpEntry? {
|
||||
val uri = Uri.parse(raw)
|
||||
val secret = uri.getQueryParameter("secret") ?: return null
|
||||
|
||||
@@ -9,22 +9,58 @@ data class PaymvQrData(
|
||||
|
||||
object PaymvQrParser {
|
||||
|
||||
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
|
||||
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
|
||||
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
|
||||
|
||||
/**
|
||||
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
||||
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
||||
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
||||
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||
*
|
||||
* Three shapes exist:
|
||||
* - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
|
||||
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
|
||||
* `35` → `20` → `01`;
|
||||
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
|
||||
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
|
||||
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
|
||||
*
|
||||
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
|
||||
* that follow it.
|
||||
*/
|
||||
fun extractBmlGatewayUrl(raw: String): String? {
|
||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
||||
return try {
|
||||
val root = parseTlv(raw)
|
||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
fun bmlQrPayTarget(raw: String): String? {
|
||||
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||
val ref = bmlQrReference(raw) ?: return null
|
||||
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||
}
|
||||
|
||||
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
|
||||
private fun supplementaryDomain(raw: String): String? = try {
|
||||
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
|
||||
private fun bmlQrReference(raw: String): String? = try {
|
||||
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
|
||||
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
|
||||
inner?.get("01")?.takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
|
||||
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
|
||||
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
|
||||
* "Unsupported payment link").
|
||||
*/
|
||||
fun bmlPayRequestKey(target: String): String {
|
||||
if (target.startsWith("https://")) return target
|
||||
return bmlQrReference(target) ?: target
|
||||
}
|
||||
|
||||
fun parse(raw: String): PaymvQrData? {
|
||||
|
||||
@@ -40,10 +40,17 @@ object ReceiptStore {
|
||||
remarks = o.optString("remarks"),
|
||||
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||
mibFromProfileName = o.optString("mibFromProfileName"),
|
||||
mibTransactionType = o.optString("mibTransactionType"),
|
||||
bmlFromName = o.optString("bmlFromName"),
|
||||
bmlReference = o.optString("bmlReference"),
|
||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||
bmlMessage = o.optString("bmlMessage")
|
||||
bmlMessage = o.optString("bmlMessage"),
|
||||
mfaisaTransactionType = o.optString("mfaisaTransactionType"),
|
||||
mfaisaFromName = o.optString("mfaisaFromName"),
|
||||
mfaisaFromMsisdn = o.optString("mfaisaFromMsisdn"),
|
||||
mfaisaToMsisdn = o.optString("mfaisaToMsisdn"),
|
||||
mfaisaTimestamp = o.optLong("mfaisaTimestamp", 0L)
|
||||
),
|
||||
savedAt = o.optLong("savedAt", 0L)
|
||||
)
|
||||
@@ -71,10 +78,17 @@ object ReceiptStore {
|
||||
put("remarks", d.remarks)
|
||||
put("mibReferenceNo", d.mibReferenceNo)
|
||||
put("mibTransactionDate", d.mibTransactionDate)
|
||||
put("mibFromProfileName", d.mibFromProfileName)
|
||||
put("mibTransactionType", d.mibTransactionType)
|
||||
put("bmlFromName", d.bmlFromName)
|
||||
put("bmlReference", d.bmlReference)
|
||||
put("bmlTimestamp", d.bmlTimestamp)
|
||||
put("bmlMessage", d.bmlMessage)
|
||||
put("mfaisaTransactionType", d.mfaisaTransactionType)
|
||||
put("mfaisaFromName", d.mfaisaFromName)
|
||||
put("mfaisaFromMsisdn", d.mfaisaFromMsisdn)
|
||||
put("mfaisaToMsisdn", d.mfaisaToMsisdn)
|
||||
put("mfaisaTimestamp", d.mfaisaTimestamp)
|
||||
put("savedAt", ts)
|
||||
})
|
||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
||||
|
||||
@@ -10,7 +10,13 @@ data class RecentPick(
|
||||
val subtitle: String,
|
||||
val colorHex: String,
|
||||
val imageHash: String?,
|
||||
val isProfileImage: Boolean
|
||||
val isProfileImage: Boolean,
|
||||
/** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide
|
||||
* per-bank selectability. Null for legacy entries; treated as unspecified. */
|
||||
val bank: String? = null,
|
||||
/** The contact category the pick was paid as — set for Fahipay payouts (e.g.
|
||||
* "FAHIPAY_RAASTAS") so picking the recent again applies the same service. */
|
||||
val contactCategory: String? = null
|
||||
)
|
||||
|
||||
object RecentsCache {
|
||||
@@ -34,6 +40,8 @@ object RecentsCache {
|
||||
put("colorHex", r.colorHex)
|
||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||
put("isProfileImage", r.isProfileImage)
|
||||
if (r.bank != null) put("bank", r.bank)
|
||||
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -51,6 +59,8 @@ object RecentsCache {
|
||||
put("colorHex", r.colorHex)
|
||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||
put("isProfileImage", r.isProfileImage)
|
||||
if (r.bank != null) put("bank", r.bank)
|
||||
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -75,7 +85,9 @@ object RecentsCache {
|
||||
subtitle = o.getString("subtitle"),
|
||||
colorHex = o.getString("colorHex"),
|
||||
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
|
||||
isProfileImage = o.optBoolean("isProfileImage", false)
|
||||
isProfileImage = o.optBoolean("isProfileImage", false),
|
||||
bank = o.optString("bank").takeIf { it.isNotBlank() },
|
||||
contactCategory = o.optString("contactCategory").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -9,10 +9,11 @@ object Totp {
|
||||
/**
|
||||
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
||||
* Uses HmacSHA1, 30-second window, 6 digits — matching standard authenticator apps.
|
||||
* [periodOffset] shifts the time window, e.g. 1 yields the next code.
|
||||
*/
|
||||
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30): String {
|
||||
fun generate(base32Secret: String, digits: Int = 6, periodSeconds: Long = 30, periodOffset: Long = 0): String {
|
||||
val key = base32Decode(base32Secret.uppercase().replace(" ", "").replace("-", ""))
|
||||
val counter = System.currentTimeMillis() / 1000L / periodSeconds
|
||||
val counter = System.currentTimeMillis() / 1000L / periodSeconds + periodOffset
|
||||
val otp = hotp(key, counter, digits)
|
||||
return otp.toString().padStart(digits, '0')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Full card details the user has verified (via NFC tap or manual entry), encrypted at rest
|
||||
* with the shared AndroidKeyStore key. Keyed by the card's identity in the cards screen
|
||||
* (e.g. "bml:<accountNumber>", "mib:<cardId>").
|
||||
*/
|
||||
object VerifiedCardStore {
|
||||
|
||||
private const val PREFS = "verified_cards"
|
||||
|
||||
data class VerifiedCard(
|
||||
val pan: String,
|
||||
val expiry: String, // MM/YY
|
||||
val cvv: String,
|
||||
val method: String, // METHOD_NFC or METHOD_MANUAL
|
||||
val verifiedAt: Long
|
||||
)
|
||||
|
||||
const val METHOD_NFC = "nfc"
|
||||
const val METHOD_MANUAL = "manual"
|
||||
|
||||
fun save(context: Context, cardKey: String, card: VerifiedCard) {
|
||||
val json = JSONObject().apply {
|
||||
put("pan", card.pan)
|
||||
put("expiry", card.expiry)
|
||||
put("cvv", card.cvv)
|
||||
put("method", card.method)
|
||||
put("verifiedAt", card.verifiedAt)
|
||||
}
|
||||
prefs(context).edit().putString(cardKey, CacheEncryption.encrypt(json.toString())).apply()
|
||||
}
|
||||
|
||||
fun load(context: Context, cardKey: String): VerifiedCard? {
|
||||
val raw = prefs(context).getString(cardKey, null) ?: return null
|
||||
return try {
|
||||
val o = JSONObject(CacheEncryption.decrypt(raw))
|
||||
VerifiedCard(
|
||||
pan = o.getString("pan"),
|
||||
expiry = o.optString("expiry"),
|
||||
cvv = o.optString("cvv"),
|
||||
method = o.optString("method"),
|
||||
verifiedAt = o.optLong("verifiedAt")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun isVerified(context: Context, cardKey: String): Boolean = prefs(context).contains(cardKey)
|
||||
|
||||
/** All stored card keys (e.g. "bml:<accountNumber>"). */
|
||||
fun keys(context: Context): Set<String> = prefs(context).all.keys
|
||||
|
||||
fun remove(context: Context, cardKey: String) {
|
||||
prefs(context).edit().remove(cardKey).apply()
|
||||
}
|
||||
|
||||
fun clear(context: Context) {
|
||||
prefs(context).edit().clear().apply()
|
||||
}
|
||||
|
||||
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
}
|
||||
@@ -36,9 +36,9 @@ object BmlCardParser {
|
||||
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
||||
"C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
|
||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||
"C8030" -> "cards/bml/master_business_debit.png"
|
||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||
|
||||
@@ -15,6 +15,8 @@ object BmlContactParser {
|
||||
network = TransferNetwork.BML,
|
||||
bankColor = contact.bankColor,
|
||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||
imageHash = contact.customerImgHash,
|
||||
profileId = contact.profileId,
|
||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package sh.sar.basedbank.util.fahipayapi
|
||||
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.ui.home.transfer.FahipayService
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
@@ -15,10 +16,13 @@ object FahipayContactParser {
|
||||
network = TransferNetwork.FAHIPAY,
|
||||
bankColor = contact.bankColor,
|
||||
detail = null, // Fahipay contacts show no detail line
|
||||
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||
imageHash = contact.customerImgHash,
|
||||
profileId = contact.profileId,
|
||||
transferSubtitle = contact.benefAccount,
|
||||
canTransfer = false,
|
||||
// Each favourites list is one payout service; the Transfer screen picks it from categoryId
|
||||
canTransfer = FahipayService.fromContactCategory(contact.benefCategoryId) != null,
|
||||
canEdit = false,
|
||||
canDelete = false
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package sh.sar.basedbank.util.mfaisaapi
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountListDisplay
|
||||
|
||||
object MfaisaAccountParser {
|
||||
|
||||
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
typeLabel = account.accountTypeName,
|
||||
balance = "${account.currencyName} ${account.availableBalance}"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package sh.sar.basedbank.util.mfaisaapi
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
|
||||
object MfaisaHistoryParser {
|
||||
|
||||
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
bankPill = if (account.profileType == "MFAISA_PAYPAL") "PP" else "MF",
|
||||
typeLabel = account.accountTypeName,
|
||||
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||
blockedBalance = null
|
||||
)
|
||||
}
|
||||
@@ -21,6 +21,8 @@ object MibContactParser {
|
||||
network = network,
|
||||
bankColor = contact.bankColor,
|
||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||
imageHash = contact.customerImgHash,
|
||||
profileId = contact.profileId,
|
||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||
|
||||
@@ -10,7 +10,7 @@ object MibHistoryParser {
|
||||
return AccountHistoryDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
bankPill = null, // MIB has no bank pill
|
||||
bankPill = "MIB",
|
||||
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
|
||||
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Dark-mode replacement for drawable/bottom_receipt_wave.jpg (988x48):
|
||||
receipt background above the teeth, footer colour below. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="329dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="988"
|
||||
android:viewportHeight="48">
|
||||
<path
|
||||
android:fillColor="@color/bml_receipt_bg"
|
||||
android:pathData="M0,0 L988,0 L988,48 L0,48 Z" />
|
||||
<path
|
||||
android:fillColor="@color/bml_receipt_footer"
|
||||
android:pathData="M0,48 L0,27 L24.08,8.28 Q30.88,3 37.67,8.28 L54.96,21.72 Q61.75,27 68.54,21.72 L85.83,8.28 Q92.63,3 99.42,8.28 L116.71,21.72 Q123.5,27 130.29,21.72 L147.58,8.28 Q154.38,3 161.17,8.28 L178.46,21.72 Q185.25,27 192.04,21.72 L209.33,8.28 Q216.13,3 222.92,8.28 L240.21,21.72 Q247,27 253.79,21.72 L271.08,8.28 Q277.88,3 284.67,8.28 L301.96,21.72 Q308.75,27 315.54,21.72 L332.83,8.28 Q339.63,3 346.42,8.28 L363.71,21.72 Q370.5,27 377.29,21.72 L394.58,8.28 Q401.38,3 408.17,8.28 L425.46,21.72 Q432.25,27 439.04,21.72 L456.33,8.28 Q463.13,3 469.92,8.28 L487.21,21.72 Q494,27 500.79,21.72 L518.08,8.28 Q524.88,3 531.67,8.28 L548.96,21.72 Q555.75,27 562.54,21.72 L579.83,8.28 Q586.63,3 593.42,8.28 L610.71,21.72 Q617.5,27 624.29,21.72 L641.58,8.28 Q648.38,3 655.17,8.28 L672.46,21.72 Q679.25,27 686.04,21.72 L703.33,8.28 Q710.13,3 716.92,8.28 L734.21,21.72 Q741,27 747.79,21.72 L765.08,8.28 Q771.88,3 778.67,8.28 L795.96,21.72 Q802.75,27 809.54,21.72 L826.83,8.28 Q833.63,3 840.42,8.28 L857.71,21.72 Q864.5,27 871.29,21.72 L888.58,8.28 Q895.38,3 902.17,8.28 L919.46,21.72 Q926.25,27 933.04,21.72 L950.33,8.28 Q957.13,3 963.92,8.28 L988,27 L988,48 Z" />
|
||||
</vector>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Gray-to-white vertical gradient at the bottom of the receipt, below the zigzag tear. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:startColor="#FFFFFF"
|
||||
android:endColor="#E5E6E7" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- White-to-gray vertical gradient leading into the zigzag tear at the top of the receipt body. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:startColor="#FFFFFF"
|
||||
android:endColor="#E5E6E7" />
|
||||
</shape>
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:startColor="#2E7D32"
|
||||
android:centerColor="#43A047"
|
||||
android:endColor="#66BB6A"
|
||||
android:angle="315"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Dhiraagu mark alone, cropped square from dhiraagu_long.xml -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="48.491"
|
||||
android:viewportHeight="48.491">
|
||||
|
||||
<group
|
||||
android:translateX="0.086"
|
||||
android:translateY="3.591">
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M22.1561 2.71843C21.9696 2.07394 21.5428 1.54073 20.9538 1.21798C20.3648 0.894216 19.6856 0.818335 19.0398 1.0045C18.394 1.19168 17.8598 1.61763 17.5354 2.20445C17.2109 2.79228 17.1349 3.47016 17.3225 4.11466L20.1397 14.2748C20.4489 15.3422 21.4424 16.0879 22.5556 16.0879C22.7918 16.0879 23.027 16.0545 23.2561 15.9887C23.9019 15.8025 24.4361 15.3756 24.7605 14.7887C25.0849 14.2009 25.16 13.523 24.9734 12.8785L22.1561 2.71843Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M13.7864 19.9944C14.4382 20.0257 15.0546 19.7475 15.528 19.2922C16.0116 18.8268 16.2863 18.2015 16.2985 17.5307C16.3117 16.8599 16.0613 16.2246 15.596 15.7409L9.39471 9.38302C8.43263 8.38542 6.83695 8.35506 5.83737 9.31523C5.3538 9.78064 5.08008 10.4059 5.0669 11.0757C5.05372 11.7465 5.30412 12.3819 5.76945 12.8645L11.9707 19.2234C11.9707 19.2234 12.655 19.9387 13.7864 19.9934V19.9944Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M9.72223 23.0954L3.10228 21.5596C2.44941 21.4038 1.77424 21.511 1.20145 21.8621C0.62867 22.2132 0.227216 22.7656 0.0700814 23.4182C-0.0860399 24.0708 0.020406 24.7446 0.372186 25.3163C0.723966 25.8879 1.27749 26.2886 1.93137 26.4444L8.55132 27.9802C8.74394 28.0258 8.94162 28.049 9.1383 28.049C10.3062 28.049 11.3118 27.2568 11.5835 26.1216C11.7396 25.469 11.6322 24.7952 11.2804 24.2236C10.9286 23.6519 10.3751 23.2513 9.72122 23.0954H9.72223Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M47.6209 19.5174C47.1373 19.052 46.4996 18.8031 45.8285 18.8163C45.1564 18.8294 44.5299 19.1026 44.0635 19.5852L38.4188 25.3563C37.9525 25.8399 37.7031 26.4753 37.7153 27.1461C37.7284 27.8169 38.0022 28.4422 38.4867 28.9076C38.9571 29.3599 39.5776 29.6098 40.2315 29.6098C40.9218 29.6098 41.5656 29.3366 42.0441 28.8398L47.6888 23.0687C48.1551 22.5851 48.4045 21.9497 48.3913 21.2799C48.3782 20.6091 48.1044 19.9839 47.6209 19.5184V19.5174Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M24.497 18.6561H28.1151C28.1151 18.6561 28.329 23.4933 24.4179 23.4933C23.1608 23.4933 21.8997 23.9344 20.8413 24.7863C18.4539 26.7097 17.9662 30.1881 19.7343 32.6902C21.6544 35.4068 25.4418 35.9694 28.0675 33.9711C29.5618 32.8329 30.3211 31.2131 30.3657 29.3676C30.3657 29.3646 30.3667 29.3615 30.3688 29.3575V2.46688C30.3688 1.06761 31.5366 -0.0787268 32.9356 0.0042377C34.2039 0.0801199 35.2085 1.13034 35.2085 2.41527V28.6361C35.2085 29.9048 35.0433 31.1696 34.6945 32.3897C34.4593 33.2103 34.1289 34.0703 33.6625 34.8625C32.9894 35.9724 32.1003 36.9771 31.0034 37.8128C26.3096 41.3873 19.6004 40.4859 16.0188 35.8004C12.4371 31.1149 13.3404 24.4201 18.0342 20.8455C18.0342 20.8455 19.0804 20.026 20.5585 19.4149C21.6189 18.9778 22.7431 18.7309 23.9475 18.6702L24.497 18.6561Z" />
|
||||
|
||||
</group>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,79 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="169dp"
|
||||
android:height="40dp"
|
||||
android:viewportWidth="169"
|
||||
android:viewportHeight="40">
|
||||
|
||||
<!-- Mark -->
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M22.1561 2.71843C21.9696 2.07394 21.5428 1.54073 20.9538 1.21798C20.3648 0.894216 19.6856 0.818335 19.0398 1.0045C18.394 1.19168 17.8598 1.61763 17.5354 2.20445C17.2109 2.79228 17.1349 3.47016 17.3225 4.11466L20.1397 14.2748C20.4489 15.3422 21.4424 16.0879 22.5556 16.0879C22.7918 16.0879 23.027 16.0545 23.2561 15.9887C23.9019 15.8025 24.4361 15.3756 24.7605 14.7887C25.0849 14.2009 25.16 13.523 24.9734 12.8785L22.1561 2.71843Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M13.7864 19.9944C14.4382 20.0257 15.0546 19.7475 15.528 19.2922C16.0116 18.8268 16.2863 18.2015 16.2985 17.5307C16.3117 16.8599 16.0613 16.2246 15.596 15.7409L9.39471 9.38302C8.43263 8.38542 6.83695 8.35506 5.83737 9.31523C5.3538 9.78064 5.08008 10.4059 5.0669 11.0757C5.05372 11.7465 5.30412 12.3819 5.76945 12.8645L11.9707 19.2234C11.9707 19.2234 12.655 19.9387 13.7864 19.9934V19.9944Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M9.72223 23.0954L3.10228 21.5596C2.44941 21.4038 1.77424 21.511 1.20145 21.8621C0.62867 22.2132 0.227216 22.7656 0.0700814 23.4182C-0.0860399 24.0708 0.020406 24.7446 0.372186 25.3163C0.723966 25.8879 1.27749 26.2886 1.93137 26.4444L8.55132 27.9802C8.74394 28.0258 8.94162 28.049 9.1383 28.049C10.3062 28.049 11.3118 27.2568 11.5835 26.1216C11.7396 25.469 11.6322 24.7952 11.2804 24.2236C10.9286 23.6519 10.3751 23.2513 9.72122 23.0954H9.72223Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M47.6209 19.5174C47.1373 19.052 46.4996 18.8031 45.8285 18.8163C45.1564 18.8294 44.5299 19.1026 44.0635 19.5852L38.4188 25.3563C37.9525 25.8399 37.7031 26.4753 37.7153 27.1461C37.7284 27.8169 38.0022 28.4422 38.4867 28.9076C38.9571 29.3599 39.5776 29.6098 40.2315 29.6098C40.9218 29.6098 41.5656 29.3366 42.0441 28.8398L47.6888 23.0687C48.1551 22.5851 48.4045 21.9497 48.3913 21.2799C48.3782 20.6091 48.1044 19.9839 47.6209 19.5184V19.5174Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M24.497 18.6561H28.1151C28.1151 18.6561 28.329 23.4933 24.4179 23.4933C23.1608 23.4933 21.8997 23.9344 20.8413 24.7863C18.4539 26.7097 17.9662 30.1881 19.7343 32.6902C21.6544 35.4068 25.4418 35.9694 28.0675 33.9711C29.5618 32.8329 30.3211 31.2131 30.3657 29.3676C30.3657 29.3646 30.3667 29.3615 30.3688 29.3575V2.46688C30.3688 1.06761 31.5366 -0.0787268 32.9356 0.0042377C34.2039 0.0801199 35.2085 1.13034 35.2085 2.41527V28.6361C35.2085 29.9048 35.0433 31.1696 34.6945 32.3897C34.4593 33.2103 34.1289 34.0703 33.6625 34.8625C32.9894 35.9724 32.1003 36.9771 31.0034 37.8128C26.3096 41.3873 19.6004 40.4859 16.0188 35.8004C12.4371 31.1149 13.3404 24.4201 18.0342 20.8455C18.0342 20.8455 19.0804 20.026 20.5585 19.4149C21.6189 18.9778 22.7431 18.7309 23.9475 18.6702L24.497 18.6561Z" />
|
||||
|
||||
<!-- Wordmark -->
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M65.8877 16.7821H67.9984C67.9984 16.7821 68.1069 19.873 65.7143 19.873C65.2196 19.873 64.4927 20.1098 63.925 20.5671C62.6426 21.6001 62.38 23.4688 63.3299 24.8135C64.361 26.2734 66.3956 26.5749 67.8058 25.5015C68.6087 24.8903 69.0172 24.0202 69.0395 23.0287C69.0395 23.0267 69.0395 23.0257 69.0416 23.0236V20.6956L69.0365 12.2565H72.1346V22.6038C72.1346 23.3434 72.0383 24.0819 71.8345 24.7932C71.6976 25.2708 71.505 25.7736 71.2333 26.235C70.84 26.8825 70.322 27.4693 69.6823 27.956C66.9441 30.0412 63.0299 29.5151 60.9415 26.7823C58.8521 24.0496 59.3793 20.1432 62.1175 18.0589C62.1175 18.0589 62.7278 17.5814 63.5905 17.2242C64.2079 16.9693 64.8648 16.8256 65.5673 16.7902L65.8877 16.7821Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M116.569 29.4027H118.68C118.68 29.4027 118.787 26.3117 116.396 26.3117C115.901 26.3117 115.174 26.075 114.607 25.6177C113.323 24.5846 113.062 22.7159 114.012 21.3713C115.043 19.9113 117.077 19.6098 118.487 20.6843C119.29 21.2954 119.699 22.1655 119.722 23.1571C119.722 23.1591 119.722 23.1601 119.724 23.1621V25.4902L119.719 29.2893H122.817V23.581C122.817 22.8414 122.721 22.1028 122.517 21.3915C122.38 20.913 122.187 20.4111 121.915 19.9498C121.522 19.3022 121.004 18.7154 120.364 18.2288C117.626 16.1435 113.712 16.6696 111.623 19.4024C109.534 22.1352 110.061 26.0416 112.799 28.1258C112.799 28.1258 113.409 28.6034 114.272 28.9605C114.891 29.2155 115.546 29.3591 116.248 29.3946L116.569 29.4027Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M132.317 29.4027H134.428C134.428 29.4027 134.535 26.3117 132.144 26.3117C131.649 26.3117 130.922 26.075 130.354 25.6177C129.071 24.5846 128.809 22.7159 129.759 21.3713C130.79 19.9113 132.825 19.6098 134.235 20.6843C135.038 21.2954 135.447 22.1655 135.47 23.1571C135.47 23.1591 135.47 23.1601 135.472 23.1621V25.4902L135.467 29.2893H138.565V23.581C138.565 22.8414 138.469 22.1028 138.265 21.3915C138.128 20.913 137.934 20.4111 137.663 19.9498C137.269 19.3022 136.75 18.7154 136.112 18.2288C133.374 16.1435 129.459 16.6696 127.371 19.4024C125.282 22.1352 125.809 26.0416 128.547 28.1258C128.547 28.1258 129.157 28.6034 130.02 28.9605C130.638 29.2155 131.294 29.3591 131.996 29.3946L132.317 29.4027Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M83.4124 16.9528C82.0955 16.9528 80.8029 17.14 80.0233 18.1376V12.2572H76.9272V29.2892H80.0233V23.3279C80.0233 20.9917 81.1446 19.9456 82.9247 19.9456C84.5579 19.9456 85.6315 20.7975 85.6315 23.0608V29.2892H88.7276V22.8655C88.7276 19.3375 86.9231 16.9528 83.4124 16.9528Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M165.904 17.1637V23.3931C165.904 25.5006 164.973 26.3829 163.527 26.4942C162.079 26.3829 161.148 25.5006 161.148 23.3931V17.1637H158.052V23.5874C158.052 27.1154 159.856 29.5001 163.366 29.5001C163.42 29.5001 163.473 29.5001 163.526 29.4981C163.578 29.4981 163.631 29.5001 163.685 29.5001C167.195 29.5001 169 27.1154 169 23.5874V17.1637H165.904Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:pathData="M95.9721 17.1224H92.876V29.2889H95.9721V17.1224Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M106.412 16.9529C105.472 16.9529 104.531 17.1684 103.687 17.5761C103.543 17.6449 103.405 17.7208 103.269 17.8007L103.267 17.8028C102.563 18.2105 101.953 18.7467 101.451 19.4034C101.218 19.7089 101.018 20.0327 100.848 20.3676C100.411 21.1628 100.173 22.0623 100.173 22.9891V29.3318H103.27V23.2491C103.255 22.6015 103.451 21.9459 103.849 21.3804C104.435 20.5507 105.393 20.056 106.413 20.056H107.834V16.9549H106.413L106.412 16.9529Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M94.3459 12.6085C95.2451 12.6085 95.973 13.335 95.973 14.2324C95.973 15.1298 95.2451 15.8563 94.3459 15.8563C93.4466 15.8563 92.7188 15.1288 92.7188 14.2324C92.7188 13.336 93.4466 12.6085 94.3459 12.6085Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#F15625"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M154.484 26.5717V23.5809C154.484 22.8413 154.387 22.1027 154.184 21.3915C154.047 20.9129 153.853 20.4111 153.582 19.9497C153.19 19.3022 152.671 18.7154 152.031 18.2287C149.293 16.1435 145.379 16.6686 143.291 19.4023C141.201 22.1351 141.728 26.0415 144.467 28.1258C144.467 28.1258 145.077 28.6043 145.94 28.9595C146.558 29.2154 147.214 29.3591 147.916 29.3945L148.237 29.4026H150.348C150.348 29.4026 150.456 26.3107 148.063 26.3107C147.57 26.3107 146.843 26.0739 146.275 25.6166C144.993 24.5846 144.731 22.7149 145.68 21.3712C146.711 19.9113 148.746 19.6098 150.156 20.6832C150.959 21.2943 151.367 22.1655 151.39 23.157C151.39 23.159 151.39 23.16 151.391 23.162V23.6052H151.386V26.5727V28.8906C151.371 30.6349 150.186 31.5202 148.427 31.5202C147.069 31.5445 145.977 31.157 144.813 30.5762L143.067 32.4399C144.376 33.5306 146.196 34.3289 148.428 34.3289C152.214 34.3289 154.625 31.8369 154.478 28.1551V26.5727H154.484V26.5717Z" />
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The Favara mark alone, cropped square from favara_long.xml -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="317.610"
|
||||
android:viewportHeight="317.610">
|
||||
|
||||
<group
|
||||
android:translateX="-123.226"
|
||||
android:translateY="-123.588">
|
||||
|
||||
<path
|
||||
android:fillColor="#005CA3"
|
||||
android:pathData="M262.476 321.671C254.92 326.026 245.261 323.404 240.951 315.848C236.596 308.314 239.212 298.678 246.735 294.323L301.63 262.653C309.197 258.287 318.845 260.903 323.167 268.47C327.521 275.988 324.905 285.636 317.333 290.001L262.476 321.671ZM272.472 371.936L272.438 371.98L238.014 391.849L237.932 391.894H237.899V391.927H237.854L233.616 394.394C232.887 394.78 232.153 395.167 231.419 395.437C227.368 397.518 223.421 399.212 219.602 400.266C210.043 402.844 201.366 401.828 193.882 394.824C185.962 387.329 178.24 379.646 170.717 371.743C163.195 363.883 155.865 355.77 148.695 347.446C132.016 328.029 123.952 305.157 123.952 282.054C123.952 258.663 132.253 235.172 148.298 215.071C158.569 202.217 169.371 190.146 180.751 178.804C191.945 167.616 203.756 157.118 216.174 147.283C236.16 131.47 258.927 123.588 281.838 123.588C304.831 123.588 327.792 131.503 347.926 147.167C360.207 156.693 372.073 167.196 383.537 178.655C394.852 189.919 405.653 202.101 415.875 215.187C431.809 235.476 440.11 259.049 440.11 282.434C440.11 305.466 432.118 328.195 415.411 347.48C404.759 359.755 394.046 371.323 383.189 382.169C372.355 392.97 360.819 403.727 348.544 414.346C334.238 426.72 317.84 434.514 300.819 437.792C283.389 441.198 265.291 439.763 248.28 433.598C240.095 430.623 235.851 421.56 238.826 413.342C241.801 405.118 250.863 400.873 259.049 403.882C270.645 408.093 282.958 409.053 294.88 406.735C306.575 404.494 317.918 399.058 327.88 390.459C338.908 380.894 349.858 370.666 360.786 359.755C371.698 348.837 381.947 337.832 391.49 326.843C402.959 313.607 408.435 298.098 408.435 282.434C408.435 265.959 402.452 249.219 390.993 234.626C381.66 222.671 371.731 211.522 361.283 201.063C350.978 190.769 340.028 181.166 328.493 172.164C314.06 160.943 297.888 155.302 281.838 155.302C265.87 155.302 249.864 160.833 235.74 172.059C224.509 180.917 213.602 190.654 203.016 201.218C192.646 211.61 182.639 222.754 173.069 234.742C161.622 249.142 155.666 265.689 155.666 282.054C155.666 297.828 161.219 313.53 172.721 326.876C179.482 334.746 186.425 342.435 193.612 349.958C199.854 356.509 206.267 362.956 212.857 369.242C214.712 368.541 216.909 367.432 219.348 366.118L301.553 318.63C309.12 314.253 318.801 316.853 323.167 324.409C327.521 331.921 324.977 341.613 317.41 345.967L272.472 371.936ZM262.476 265.689C254.92 270.016 245.261 267.427 240.951 259.871C236.596 252.304 239.212 242.657 246.735 238.329L301.63 206.626C309.197 202.305 318.845 204.893 323.167 212.449C327.521 220.016 324.905 229.653 317.333 233.975L262.476 265.689Z" />
|
||||
|
||||
</group>
|
||||
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user