Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
@@ -24,11 +24,20 @@ jobs:
|
|||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
|
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
if git tag -l | grep -q "^v${VERSION}$"; then
|
BEFORE="${{ github.event.before }}"
|
||||||
echo "Tag v${VERSION} already exists, skipping"
|
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
||||||
|
BEFORE="HEAD~1"
|
||||||
|
fi
|
||||||
|
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
|
|
||||||
|
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
|
||||||
|
echo "versionCode unchanged (${VERSION_CODE}), skipping"
|
||||||
echo "should_release=false" >> $GITHUB_OUTPUT
|
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||||
|
elif git tag -l | grep -q "^v${VERSION}$"; then
|
||||||
|
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
|
||||||
|
exit 1
|
||||||
else
|
else
|
||||||
echo "New version detected: v${VERSION}"
|
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
|
||||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -27,24 +27,21 @@ jobs:
|
|||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_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"
|
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
|
|
||||||
- name: Extract release notes
|
- name: Extract release notes
|
||||||
run: |
|
run: |
|
||||||
echo "No release notes" > release_notes.md
|
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
# VERSION="${{ gitea.ref_name }}"
|
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||||
# VERSION="${VERSION#v}"
|
|
||||||
#
|
if [ -f "$CHANGELOG" ]; then
|
||||||
# awk -v ver="$VERSION" '
|
cp "$CHANGELOG" release_notes.md
|
||||||
# BEGIN { found=0 }
|
else
|
||||||
# /^## \[/ {
|
echo "No changelog found at $CHANGELOG"
|
||||||
# if (found) exit
|
echo "No release notes" > release_notes.md
|
||||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
fi
|
||||||
# }
|
|
||||||
# found { print }
|
|
||||||
# ' CHANGELOG.md > release_notes.md
|
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
env:
|
env:
|
||||||
@@ -53,7 +50,7 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
TITLE="${APP_NAME} ${TAG}"
|
TITLE="${APP_NAME} ${TAG}"
|
||||||
NOTES_FILE="release_notes.md"
|
NOTES_FILE="release_notes.md"
|
||||||
@@ -101,12 +98,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
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" \
|
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||||
-F "chat_id=${TG_CHAT_ID}" \
|
-F "chat_id=${TG_CHAT_ID}" \
|
||||||
-F "document=@${ASSET_PATH}" \
|
-F "document=@${ASSET_PATH}" \
|
||||||
-F "caption=${CAPTION}"
|
--form-string "caption=${CAPTION}"
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
|||||||
|
|
||||||
- Android 8.0+ (API 26)
|
- Android 8.0+ (API 26)
|
||||||
- Existing accounts with MIB, BML, or Fahipay
|
- 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
|
## Download APK
|
||||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md).
|
||||||
|
|
||||||
## Privacy
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 24
|
versionCode = 34
|
||||||
versionName = "1.0.23"
|
versionName = "1.0.33"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name">
|
android:label="@string/app_name">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -45,20 +46,24 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LockActivity"
|
android:name=".LockActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.onboarding.OnboardingActivity"
|
android:name=".ui.onboarding.OnboardingActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.login.LoginActivity"
|
android:name=".ui.login.LoginActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustPan" />
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
@@ -69,6 +74,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".nfc.BmlTapToPayActivity"
|
android:name=".nfc.BmlTapToPayActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/Theme.BasedBank" />
|
android:theme="@style/Theme.BasedBank" />
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import java.util.concurrent.TimeUnit
|
|||||||
|
|
||||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
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})"
|
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 const val BML_APP_VERSION = "2.1.44.348"
|
||||||
|
|
||||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
@@ -14,9 +16,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.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 =
|
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||||
Request.Builder().url(url)
|
Request.Builder().url(url)
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
.header("accept", "application/json")
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("user-agent", BML_USER_AGENT)
|
||||||
|
.header("authorization", "Bearer ${session.accessToken}")
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class BmlLoginFlow {
|
|||||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
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 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 cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
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 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.
|
||||||
|
*
|
||||||
|
* 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 {
|
||||||
|
object Success : 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
|
||||||
|
|
||||||
|
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
|
||||||
|
|
||||||
|
// 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 Result.Success
|
||||||
|
"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 Result.Success
|
||||||
|
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||||
|
}
|
||||||
|
Thread.sleep(POLL_MS / 2)
|
||||||
|
}
|
||||||
|
return Result.Failure("Payment status unknown — check with the merchant before retrying")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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.
|
||||||
|
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 (!html.contains("incorrect", true) && !html.contains("expired", true)) break
|
||||||
|
retry = true
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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. */
|
||||||
|
fun announceBrowser(transactionId: String) {
|
||||||
|
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
|
||||||
|
patch(transactionId, JSONObject().put("fx", "reset"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
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(
|
data class BmlQrPayResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val merchant: String = "",
|
val merchant: String = "",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -10,17 +11,27 @@ class BmlQrPayClient {
|
|||||||
private val client = newBmlApiClient()
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a BML QR URL to merchant details.
|
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
* `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,
|
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 ->
|
return client.newCall(request).execute().use { response ->
|
||||||
val body = response.body?.string() ?: throw Exception("No 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)
|
val json = JSONObject(body)
|
||||||
if (!json.optBoolean("success"))
|
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 payload = json.getJSONObject("payload")
|
||||||
val addr2 = payload.optString("narrative2").trim()
|
val addr2 = payload.optString("narrative2").trim()
|
||||||
val addr3 = payload.optString("narrative3").trim()
|
val addr3 = payload.optString("narrative3").trim()
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
}
|
}
|
||||||
.build()
|
.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 ───────────────────────────────────────────────────
|
// ─── Public entry point ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -234,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun setupAccountSearch() {
|
private fun setupAccountSearch() {
|
||||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||||
|
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||||
|
performLookup()
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performLookup() {
|
private fun performLookup() {
|
||||||
@@ -387,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
// Auto-fill alias with existing alias or name
|
// Auto-fill alias with existing alias or name
|
||||||
if (binding.etAlias.text.isNullOrBlank()) {
|
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)
|
binding.etCurrency.setText(validation.currency)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupMerchant(qrUrl: String) {
|
private fun lookupMerchant(qrUrl: String) {
|
||||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val session = app.anyBmlSession() ?: run {
|
val session = app.anyBmlSession() ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||||
catch (_: Exception) { null }
|
catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (_binding == null) return@launch
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||||
import sh.sar.basedbank.util.ContactDisplay
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
class ContactsAdapter(
|
class ContactsAdapter(
|
||||||
private val imageCache: MutableMap<String, Bitmap>,
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onImageNeeded: (hash: String) -> Unit,
|
private val onImageNeeded: (hash: String) -> Unit,
|
||||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
private val onContactClick: (ContactDisplay) -> Unit,
|
||||||
private val onTransferClick: (ContactDisplay) -> Unit
|
private val onTransferClick: (ContactDisplay) -> Unit
|
||||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
@@ -66,12 +65,9 @@ class ContactsAdapter(
|
|||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.btnEditContact.setOnClickListener {
|
binding.root.setOnClickListener {
|
||||||
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
binding.btnDeleteContact.setOnClickListener {
|
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
@@ -107,37 +103,33 @@ class ContactsAdapter(
|
|||||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnTransferContact.visibility =
|
binding.btnTransferContact.visibility =
|
||||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
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) {
|
if (photo != null) {
|
||||||
binding.ivContactPhoto.setImageBitmap(photo)
|
binding.ivContactPhoto.setImageBitmap(photo)
|
||||||
} else {
|
} else {
|
||||||
|
val sizePx = binding.ivContactPhoto.context.resources
|
||||||
|
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||||
|
.coerceAtLeast(96)
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
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.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
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.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
|
|||||||
ContactsAdapter(
|
ContactsAdapter(
|
||||||
imageCache = sharedImageCache,
|
imageCache = sharedImageCache,
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
onContactClick = { contact -> showDetails(contact) },
|
||||||
onTransferClick = { contact -> openTransfer(contact) }
|
onTransferClick = { contact -> openTransfer(contact) }
|
||||||
).also { a ->
|
).also { a ->
|
||||||
a.setFilter(page.categoryId, currentSearch)
|
a.setFilter(page.categoryId, currentSearch)
|
||||||
@@ -170,7 +177,12 @@ class ContactsFragment : Fragment() {
|
|||||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openTransfer(contact: 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(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.accountNumber,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.name,
|
displayName = contact.name,
|
||||||
@@ -181,10 +193,26 @@ class ContactsFragment : Fragment() {
|
|||||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: ContactDisplay) {
|
internal fun confirmDelete(contact: ContactDisplay) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
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)
|
.setTitle(R.string.contact_delete_title)
|
||||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
.setMessage(message)
|
||||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
@@ -121,7 +121,8 @@ class DashboardFragment : Fragment() {
|
|||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
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) }
|
.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) }
|
.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 defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
val ordered = if (defaultNum != null) {
|
val ordered = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
|||||||
notifyDataSetChanged()
|
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()
|
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()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
|
|||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
class FinancingFragment : Fragment() {
|
class FinancingFragment : Fragment() {
|
||||||
|
|
||||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
|||||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
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()
|
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
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.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
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 lockApp() = lock()
|
||||||
|
|
||||||
fun notifyWheelLockTap() {
|
fun notifyWheelLockTap() {
|
||||||
@@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
}
|
}
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
lastConfig = Configuration(resources.configuration)
|
||||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
insets
|
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 ->
|
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||||
val frag = when (item.itemId) {
|
val frag = when (item.itemId) {
|
||||||
@@ -244,9 +308,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||||
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||||
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
val cachedCards = CardsCache.load(this)
|
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)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||||
@@ -528,9 +592,13 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
private fun routeSharedQrText(text: String) {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let {
|
||||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it))
|
||||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
return
|
||||||
|
}
|
||||||
|
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||||
|
if (bmlTarget != null) {
|
||||||
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||||
@@ -774,47 +842,13 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
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) {
|
for (profile in savedProfiles) {
|
||||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
if (profile.profileId in hiddenProfiles) continue
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tokenKnownExpired) {
|
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
|
||||||
tryRefresh()
|
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
|
||||||
} catch (_: AuthExpiredException) {
|
|
||||||
tryRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
refreshErrors.add("NO_INTERNET")
|
refreshErrors.add("NO_INTERNET")
|
||||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
@@ -985,15 +1019,17 @@ fun applyNavLabelVisibility() {
|
|||||||
refreshBmlLoanDetails()
|
refreshBmlLoanDetails()
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
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> {
|
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
||||||
val store = CredentialStore(this@HomeActivity)
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
|
||||||
return filter { acc ->
|
return filter { acc ->
|
||||||
|
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
|
||||||
when (acc.bank) {
|
when (acc.bank) {
|
||||||
"MIB" -> {
|
"MIB" -> {
|
||||||
val loginId = acc.loginTag.removePrefix("mib_")
|
val loginId = acc.loginTag.removePrefix("mib_")
|
||||||
@@ -1022,10 +1058,140 @@ fun applyNavLabelVisibility() {
|
|||||||
return filter { it.profileId !in hidden }
|
return filter { it.profileId !in hidden }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
/** Drops MIB cards belonging to profiles the user has hidden. */
|
||||||
fun applyProfileVisibility() {
|
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
|
||||||
val current = viewModel.accounts.value ?: return
|
val store = CredentialStore(this@HomeActivity)
|
||||||
viewModel.accounts.value = current.filterVisibleAccounts()
|
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) {
|
private fun refreshBmlLimits(session: BmlSession) {
|
||||||
@@ -1259,7 +1425,7 @@ fun applyNavLabelVisibility() {
|
|||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshMibCards(loginId, session, profiles)
|
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1284,7 +1450,8 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
if (cards.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
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
|
existing += cards
|
||||||
viewModel.mibCards.postValue(existing)
|
viewModel.mibCards.postValue(existing)
|
||||||
CardsCache.save(this@HomeActivity, existing)
|
CardsCache.save(this@HomeActivity, existing)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
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.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
sealed class CardItem {
|
sealed class CardItem {
|
||||||
data class Mib(val card: MibCard) : CardItem()
|
abstract val loginTag: String
|
||||||
data class Bml(val account: BankAccount) : CardItem()
|
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() {
|
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
/** 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())
|
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||||
/** BML loan details keyed by account internalId. */
|
/** BML loan details keyed by account internalId. */
|
||||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
|||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
val 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)
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
@@ -36,4 +56,7 @@ class HomeViewModel : ViewModel() {
|
|||||||
* for HTTP 5xx server errors from specific banks.
|
* for HTTP 5xx server errors from specific banks.
|
||||||
*/
|
*/
|
||||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
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
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
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.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PersistableBundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -18,13 +29,23 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
|
import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding
|
||||||
|
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.OtpauthParser
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class OtpFragment : Fragment() {
|
class OtpFragment : Fragment() {
|
||||||
@@ -32,7 +53,35 @@ class OtpFragment : Fragment() {
|
|||||||
private var _binding: FragmentOtpBinding? = null
|
private var _binding: FragmentOtpBinding? = null
|
||||||
private val binding get() = _binding!!
|
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>) :
|
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||||
@@ -45,18 +94,19 @@ class OtpFragment : Fragment() {
|
|||||||
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.b.tvOtpLabel.text = entries[position].label
|
val entry = entries[position]
|
||||||
update(holder.b, entries[position].seed)
|
val b = holder.b
|
||||||
holder.b.root.setOnClickListener {
|
b.tvOtpBank.text = entry.bank
|
||||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
b.tvOtpLabel.text = entry.name ?: "Authenticator"
|
||||||
if (code.isNotEmpty()) {
|
b.ivBankLogo.setImageResource(
|
||||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
)
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
update(b, entry.seed)
|
||||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
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() {
|
fun tick() {
|
||||||
@@ -71,9 +121,196 @@ class OtpFragment : Fragment() {
|
|||||||
val secondsInPeriod = (epochSeconds % 30).toInt()
|
val secondsInPeriod = (epochSeconds % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
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.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
||||||
|
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
|
||||||
b.otpProgress.progress = remaining
|
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 store = CredentialStore(requireContext())
|
||||||
val app = requireActivity().application as BasedBankApp
|
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()) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
val name = store.loadMibFullName(loginId)
|
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()) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
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.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
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
|
// Fetch real names in background if not yet cached, then refresh labels
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -120,9 +363,8 @@ class OtpFragment : Fragment() {
|
|||||||
mobile = profile.mobile,
|
mobile = profile.mobile,
|
||||||
enrolled = profile.enrolled
|
enrolled = profile.enrolled
|
||||||
))
|
))
|
||||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,9 +383,8 @@ class OtpFragment : Fragment() {
|
|||||||
idCard = info.idCard,
|
idCard = info.idCard,
|
||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
))
|
))
|
||||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +406,8 @@ class OtpFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
adapter = null
|
||||||
|
scanTarget = null
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
|
import android.text.TextPaint
|
||||||
|
import android.text.TextUtils
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
@@ -48,6 +51,8 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private var selectedAccount: BankAccount? = null
|
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 generatedBitmap: Bitmap? = null
|
||||||
private var generateJob: Job? = null
|
private var generateJob: Job? = null
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
@@ -59,7 +64,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
return binding.root
|
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?) {
|
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
|
val basePaddingBottom = view.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||||
@@ -68,7 +94,15 @@ class PayMvQrFragment : Fragment() {
|
|||||||
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||||
insets
|
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.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
||||||
@@ -119,24 +153,26 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun generateQr() {
|
private suspend fun generateQr() {
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
val acquirer = when (account.bank) {
|
val acquirer = when (target.bank) {
|
||||||
"BML" -> "MALBMVMV"
|
"BML" -> "MALBMVMV"
|
||||||
"MIB" -> "MADVMVMV"
|
"MIB" -> "MADVMVMV"
|
||||||
"FAHIPAY" -> "FAHIMVMV"
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
else -> "MADVMVMV"
|
else -> "MADVMVMV"
|
||||||
}
|
}
|
||||||
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
|
||||||
?.replace(",", "")
|
val amountFormatted = amountRaw
|
||||||
?.toDoubleOrNull()
|
?.toDoubleOrNull()
|
||||||
?.takeIf { it > 0 }
|
?.takeIf { it > 0 }
|
||||||
?.let { "%.2f".format(it) }
|
?.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 ctx = requireContext()
|
||||||
val includePhone = binding.switchIncludePhone.isChecked
|
val account = selectedAccount
|
||||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
|
||||||
val store = CredentialStore(ctx)
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||||
val mobile = if (includePhone) {
|
val store = CredentialStore(ctx)
|
||||||
when (account.bank) {
|
when (account.bank) {
|
||||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||||
@@ -145,17 +181,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
when {
|
when {
|
||||||
m.startsWith("+") -> m
|
m.startsWith("+") -> m
|
||||||
m.length == 7 -> "+960$m"
|
m.length == 7 -> "+960$m"
|
||||||
|
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
|
||||||
else -> m
|
else -> m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val purpose = binding.etReference.text?.toString()?.trim()
|
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 bmp = withContext(Dispatchers.Default) {
|
||||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank)
|
||||||
renderQrCard(ctx, account, payload, amountFormatted)
|
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
|
||||||
|
else renderQrCard(ctx, target, payload, reference)
|
||||||
}
|
}
|
||||||
if (_binding == null) return
|
if (_binding == null) return
|
||||||
generatedBitmap = bmp
|
generatedBitmap = bmp
|
||||||
@@ -174,14 +221,19 @@ class PayMvQrFragment : Fragment() {
|
|||||||
acquirer: String,
|
acquirer: String,
|
||||||
amountStr: String?,
|
amountStr: String?,
|
||||||
mobile: String?,
|
mobile: String?,
|
||||||
purpose: String
|
purpose: String?,
|
||||||
|
ref: String,
|
||||||
|
bank: String
|
||||||
): String {
|
): String {
|
||||||
fun tlv(tag: String, value: String): String {
|
fun tlv(tag: String, value: String): String {
|
||||||
val len = value.length
|
val len = value.length
|
||||||
return tag + (if (len < 10) "0$len" else "$len") + value
|
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||||
}
|
}
|
||||||
val format = tlv("00", "01")
|
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 sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
val sub01 = tlv("01", acquirer)
|
val sub01 = tlv("01", acquirer)
|
||||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||||
@@ -191,21 +243,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||||
val mcc = tlv("52", "0000")
|
val mcc = tlv("52", "0000")
|
||||||
val currency = tlv("53", "462")
|
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 country = tlv("58", "MV")
|
||||||
val name = tlv("59", accountName.take(25))
|
val name = tlv("59", accountName.uppercase().take(25))
|
||||||
val ref = generateReference()
|
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
|
||||||
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
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()
|
val timestamp = java.time.LocalDateTime.now()
|
||||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
.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 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)
|
return prefix + crc16(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateReference(): String {
|
private fun generateReference(length: Int): String {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
return (1..9).map { chars.random() }.joinToString("")
|
return (1..length).map { chars.random() }.joinToString("")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun crc16(data: String): String {
|
private fun crc16(data: String): String {
|
||||||
@@ -222,88 +281,127 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
// ── QR card rendering ────────────────────────────────────────────────────
|
// ── 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(
|
private fun renderQrCard(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
account: BankAccount,
|
target: QrTarget,
|
||||||
qrPayload: String,
|
qrPayload: String,
|
||||||
amountStr: String?
|
qrId: String
|
||||||
): Bitmap {
|
): Bitmap {
|
||||||
val W = 900
|
val sw = SCREEN_WIDTH_DP
|
||||||
val H = 1080
|
fun px(dp: Float) = dp * PX_PER_DP
|
||||||
val outerCorner = 48f
|
val mmaBlue = Color.parseColor("#0E5CA4")
|
||||||
val boxBlue = Color.parseColor("#2272B7")
|
|
||||||
val footerBlue = Color.parseColor("#1A5799")
|
|
||||||
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
|
||||||
|
|
||||||
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
val 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 canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
// Clip to outer rounded card shape
|
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
|
||||||
val outerPath = Path()
|
val outerPath = Path().apply {
|
||||||
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), 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
|
|
||||||
}
|
}
|
||||||
|
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 ->
|
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||||
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||||
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||||
val maxW = 180f; val maxH = 76f
|
// resizeMode "contain" inside the logo box, left-aligned in the row
|
||||||
val scale = minOf(maxW / nW, maxH / nH)
|
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH)
|
||||||
val lW = (nW * scale).toInt()
|
val lW = (nW * scale).toInt()
|
||||||
val lH = (nH * scale).toInt()
|
val lH = (nH * scale).toInt()
|
||||||
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
|
||||||
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
val lTop = (px(rowCenterY) - lH / 2f).toInt()
|
||||||
|
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
|
||||||
d.draw(canvas)
|
d.draw(canvas)
|
||||||
}
|
}
|
||||||
|
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
|
||||||
// --- "PayMV QR" top-right ---
|
paint.color = mmaBlue
|
||||||
paint.color = Color.parseColor("#1A1A2E")
|
paint.typeface = footerPaint.typeface
|
||||||
paint.textSize = 36f
|
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
|
||||||
paint.textAlign = Paint.Align.RIGHT
|
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 ---
|
// --- qrCard: mmaBlue, radius 16, 40dp side margins ---
|
||||||
paint.color = boxBlue
|
val qrCardL = 2f + 40f
|
||||||
paint.textAlign = Paint.Align.LEFT
|
val qrCardR = cardW - 2f - 40f
|
||||||
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
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)
|
if (hasName) {
|
||||||
paint.color = Color.WHITE
|
val maxNameW = px(qrCardR - qrCardL)
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
if (namePaint.measureText(nameText) > maxNameW) {
|
||||||
paint.textAlign = Paint.Align.CENTER
|
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||||
val nameText = account.accountBriefName.uppercase()
|
}
|
||||||
paint.textSize = 36f
|
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
|
||||||
val maxNameW = boxR - boxL - 48f
|
|
||||||
if (paint.measureText(nameText) > maxNameW) {
|
|
||||||
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
|
||||||
}
|
|
||||||
val nameBaseline = boxT + 68f
|
|
||||||
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
|
||||||
|
|
||||||
// Optional amount below name
|
|
||||||
val qrTopY: Float
|
|
||||||
if (!amountStr.isNullOrBlank()) {
|
|
||||||
paint.textSize = 28f
|
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
|
||||||
val amtBaseline = nameBaseline + 42f
|
|
||||||
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
|
||||||
qrTopY = amtBaseline + 20f
|
|
||||||
} else {
|
|
||||||
qrTopY = nameBaseline + 26f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR code — white modules on the same blue as the box background
|
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
|
||||||
val availH = boxB - qrTopY - 24f
|
val qrPx = px(qrSize).toInt()
|
||||||
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
val qrLeft = px(cardW / 2) - qrPx / 2f
|
||||||
val qrLeft = ((W - qrPx) / 2).toFloat()
|
|
||||||
try {
|
try {
|
||||||
val hints = mapOf(
|
val hints = mapOf(
|
||||||
EncodeHintType.MARGIN to 0,
|
EncodeHintType.MARGIN to 0,
|
||||||
@@ -313,23 +411,150 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val pixels = IntArray(qrPx * qrPx)
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
for (y in 0 until qrPx) {
|
for (y in 0 until qrPx) {
|
||||||
for (x 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)
|
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()
|
qrBm.recycle()
|
||||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
// --- Dark blue footer ---
|
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
|
||||||
paint.color = footerBlue
|
if (qrId.isNotEmpty()) {
|
||||||
paint.textAlign = Paint.Align.LEFT
|
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
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.color = Color.WHITE
|
||||||
paint.textSize = 32f
|
canvas.drawPath(Path().apply {
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
addRoundRect(
|
||||||
paint.textAlign = Paint.Align.CENTER
|
RectF(6f, 6f, w - 6f, 1174f),
|
||||||
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
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
|
return bm
|
||||||
}
|
}
|
||||||
@@ -338,13 +563,13 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun shareQr() {
|
private fun shareQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val uri = withContext(Dispatchers.IO) {
|
val uri = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val dir = File(requireContext().cacheDir, "qr")
|
val dir = File(requireContext().cacheDir, "qr")
|
||||||
dir.mkdirs()
|
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")
|
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||||
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
@@ -366,11 +591,11 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun saveQr() {
|
private fun saveQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
try {
|
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"
|
val filename = "${safeName}_PayMV_QR.png"
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -412,6 +637,25 @@ class PayMvQrFragment : Fragment() {
|
|||||||
_binding = null
|
_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 ──────────────────────────────────────────────
|
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||||
|
|
||||||
private inner class QrAccountAdapter(
|
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.bml.BmlTapToPayClient
|
||||||
import sh.sar.basedbank.api.mib.MibCardsClient
|
import sh.sar.basedbank.api.mib.MibCardsClient
|
||||||
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||||
|
import sh.sar.basedbank.nfc.EmvCardReader
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import com.google.android.material.textfield.TextInputEditText
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
import com.google.android.material.textfield.TextInputLayout
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
|
import sh.sar.basedbank.databinding.DialogCardManualVerifyBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentCardsBinding
|
import sh.sar.basedbank.databinding.FragmentCardsBinding
|
||||||
import sh.sar.basedbank.util.CardsCache
|
import sh.sar.basedbank.util.CardsCache
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
import sh.sar.basedbank.util.VerifiedCardStore
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
import sh.sar.basedbank.util.NfcPaymentUtil
|
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
@@ -77,10 +80,10 @@ class CardsFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
@@ -123,15 +126,11 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val screenW = resources.displayMetrics.widthPixels
|
stackAdapter = CardStackAdapter()
|
||||||
val peekPx = screenW / 8
|
|
||||||
cardWidth = screenW - 2 * peekPx
|
|
||||||
|
|
||||||
stackAdapter = CardStackAdapter(cardWidth)
|
|
||||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
binding.rvCards.adapter = stackAdapter
|
binding.rvCards.adapter = stackAdapter
|
||||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
|
||||||
binding.rvCards.clipToPadding = false
|
binding.rvCards.clipToPadding = false
|
||||||
|
applyCarouselWidth()
|
||||||
|
|
||||||
val snapHelper = PagerSnapHelper()
|
val snapHelper = PagerSnapHelper()
|
||||||
snapHelper.attachToRecyclerView(binding.rvCards)
|
snapHelper.attachToRecyclerView(binding.rvCards)
|
||||||
@@ -269,6 +268,253 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.btnBlock.setOnClickListener(wip)
|
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) {
|
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
|
||||||
@@ -404,6 +650,7 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setManageMode(enabled: Boolean) {
|
private fun setManageMode(enabled: Boolean) {
|
||||||
|
if (!enabled) setVerifyMode(false)
|
||||||
isManageMode = enabled
|
isManageMode = enabled
|
||||||
if (!enabled) managedCardKey = null
|
if (!enabled) managedCardKey = null
|
||||||
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
||||||
@@ -445,6 +692,10 @@ class CardsFragment : Fragment() {
|
|||||||
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
|
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
|
||||||
binding.btnChangePin.isEnabled = !mibFrozen
|
binding.btnChangePin.isEnabled = !mibFrozen
|
||||||
binding.btnBlock.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() {
|
private fun rebindManagedCardIfNeeded() {
|
||||||
@@ -637,8 +888,13 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
// ── 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) {
|
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||||
isTapMode = enabled
|
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)
|
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||||
if (enabled) enterTapMode(item!!) else exitTapMode()
|
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||||
}
|
}
|
||||||
@@ -904,7 +1160,9 @@ class CardsFragment : Fragment() {
|
|||||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||||
val mibInactive = 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
|
// Move default BML card to front
|
||||||
cards = if (defaultNum != null) {
|
cards = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
@@ -957,6 +1215,25 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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() {
|
private fun applyCardScales() {
|
||||||
val rv = binding.rvCards
|
val rv = binding.rvCards
|
||||||
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
||||||
@@ -1016,6 +1293,10 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onBackPressed(): Boolean {
|
fun onBackPressed(): Boolean {
|
||||||
|
if (isVerifyMode) {
|
||||||
|
setVerifyMode(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (isTapMode) {
|
if (isTapMode) {
|
||||||
setTapMode(false)
|
setTapMode(false)
|
||||||
return true
|
return true
|
||||||
@@ -1029,6 +1310,7 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
if (isVerifyMode) stopVerifyReader()
|
||||||
if (isTapMode) {
|
if (isTapMode) {
|
||||||
BmlHostCardEmulatorService.clearToken()
|
BmlHostCardEmulatorService.clearToken()
|
||||||
BmlHostCardEmulatorService.onTransactionComplete = null
|
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||||
@@ -1037,7 +1319,9 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
if (isVerifyMode) startVerifyReader()
|
||||||
requireActivity().title = getString(when {
|
requireActivity().title = getString(when {
|
||||||
|
isVerifyMode -> R.string.card_verify_title
|
||||||
isTapMode -> R.string.card_pay_nfc
|
isTapMode -> R.string.card_pay_nfc
|
||||||
isManageMode -> R.string.card_manage
|
isManageMode -> R.string.card_manage
|
||||||
else -> R.string.nav_pay_with_card
|
else -> R.string.nav_pay_with_card
|
||||||
@@ -1045,6 +1329,7 @@ class CardsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
if (isVerifyMode) stopVerifyReader()
|
||||||
tapAnimView?.stopAnimation()
|
tapAnimView?.stopAnimation()
|
||||||
tapAnimView = null
|
tapAnimView = null
|
||||||
BmlHostCardEmulatorService.clearToken()
|
BmlHostCardEmulatorService.clearToken()
|
||||||
@@ -1053,7 +1338,7 @@ class CardsFragment : Fragment() {
|
|||||||
_binding = null
|
_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()
|
private var items: List<CardItem> = emptyList()
|
||||||
|
|
||||||
fun update(newItems: List<CardItem>) {
|
fun update(newItems: List<CardItem>) {
|
||||||
@@ -1068,6 +1353,8 @@ class CardsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.bind(items[position])
|
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
|
// 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 fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
|
||||||
val scale = 1f - 0.18f * fraction
|
val scale = 1f - 0.18f * fraction
|
||||||
|
|||||||
@@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
val isDark = prefs.getString("theme", "system") == "dark"
|
val isDark = prefs.getString("theme", "system") == "dark"
|
||||||
updatePitchBlackState(isDark)
|
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
|
// Accent color
|
||||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||||
binding.accentToggle.check(when (savedPreset) {
|
binding.accentToggle.check(when (savedPreset) {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -30,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlProfile
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
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.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
@@ -359,65 +363,86 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||||
|
|
||||||
private fun buildLoginsSection() {
|
private fun buildLoginsSection() {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val container = binding.loginsContainer
|
val container = binding.loginsContainer
|
||||||
container.removeAllViews()
|
container.removeAllViews()
|
||||||
|
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
val entries = mutableListOf<LoginEntry>()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
|
||||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
for (loginId in store.getMibLoginIds()) {
|
||||||
|
|
||||||
for (loginId in mibLoginIds) {
|
|
||||||
val profile = store.loadMibUserProfile(loginId)
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
val mibProfiles = store.loadMibProfiles(loginId)
|
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)
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
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)
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in fahipayLoginIds) {
|
for (loginId in store.getFahipayLoginIds()) {
|
||||||
val profile = store.loadFahipayUserProfile(loginId)
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||||
showFahipayLoginDetails(store, loginId, profile)
|
showFahipayLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in mfaisaLoginIds) {
|
for (loginId in store.getMfaisaLoginIds()) {
|
||||||
val profile = store.loadMfaisaUserProfile(loginId)
|
val profile = store.loadMfaisaUserProfile(loginId)
|
||||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||||
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
|
||||||
showMfaisaLoginDetails(store, loginId, profile)
|
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 ctx = requireContext()
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
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
|
isClickable = true; isFocusable = true
|
||||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
background = ta.getDrawable(0); ta.recycle()
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
setOnClickListener { onClick() }
|
setOnClickListener { onClick() }
|
||||||
|
tag = dragTag
|
||||||
}
|
}
|
||||||
val logo = ImageView(ctx).apply {
|
val logo = ImageView(ctx).apply {
|
||||||
setImageResource(logoRes)
|
setImageResource(logoRes)
|
||||||
@@ -430,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
row.addView(logo); row.addView(tvName)
|
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)
|
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(
|
private fun showMibLoginDetails(
|
||||||
store: CredentialStore,
|
store: CredentialStore,
|
||||||
loginId: String,
|
loginId: String,
|
||||||
@@ -443,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||||
val hidden = originalHidden.toMutableSet()
|
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 scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -485,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 toggleRows = mibProfiles.map { p ->
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
@@ -526,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
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) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (p, toggle) ->
|
toggleRows.forEach { (p, toggle) ->
|
||||||
// Disable the sole remaining visible toggle so it can't be turned off
|
// Disable the sole remaining visible toggle so it can't be turned off
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
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)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -559,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 {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenMibProfileIds(loginId, hidden)
|
store.setHiddenMibProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
}
|
}
|
||||||
val originalHidden = hidden.toSet()
|
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 scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -630,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 toggleRows = bmlProfiles.map { p ->
|
||||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||||
@@ -677,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
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) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (_, toggle) ->
|
toggleRows.forEach { (_, toggle) ->
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
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)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -720,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 {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenBmlProfileIds(loginId, hidden)
|
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val masked = "••••••"
|
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 scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -997,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))
|
.setTitle(getString(R.string.fahipay_name))
|
||||||
.setView(scroll)
|
.setView(scroll)
|
||||||
.setPositiveButton(R.string.close, null)
|
.apply {
|
||||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
setNeutralButton(R.string.close, null)
|
||||||
|
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.show()
|
.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) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
@@ -1087,6 +1379,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||||
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||||
val originalHidden = hidden.toSet()
|
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).
|
// 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.
|
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||||
@@ -1142,6 +1436,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 toggleRows = profileRows.map { row ->
|
||||||
val v = LinearLayout(ctx).apply {
|
val v = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
@@ -1164,6 +1461,7 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
v.addView(label)
|
v.addView(label)
|
||||||
v.addView(toggle)
|
v.addView(toggle)
|
||||||
container.addView(v)
|
container.addView(v)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
|
||||||
row to toggle
|
row to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1470,8 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
toggleRows.forEach { (_, toggle) ->
|
toggleRows.forEach { (_, toggle) ->
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -1199,11 +1498,20 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
saveBtn.setOnClickListener {
|
||||||
|
// All pockets come back in one login response, so hiding/unhiding never needs a request
|
||||||
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, pockets, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
(activity as? HomeActivity)?.applyVisibility()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,15 +39,19 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
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.api.mib.MibIpsAccountInfo
|
||||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemPickerRowBinding
|
||||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||||
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||||
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
|
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
|
||||||
|
import sh.sar.basedbank.ui.home.transfer.TransferDraft
|
||||||
import sh.sar.basedbank.util.AccountListParser
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
@@ -64,7 +68,16 @@ class TransferFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private var selectedAccount: BankAccount? = null
|
/**
|
||||||
|
* The form lives on the activity's ViewModel (see [TransferDraft]) so a tab switch or a
|
||||||
|
* theme/language recreation repaints it rather than starting over. The properties below are
|
||||||
|
* the fragment's view of it.
|
||||||
|
*/
|
||||||
|
private val draft: TransferDraft get() = viewModel.transferDraft
|
||||||
|
|
||||||
|
private var selectedAccount: BankAccount?
|
||||||
|
get() = draft.selectedAccount
|
||||||
|
set(value) { draft.selectedAccount = value }
|
||||||
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
|
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -75,23 +88,41 @@ class TransferFragment : Fragment() {
|
|||||||
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
|
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
|
||||||
|
|
||||||
// Resolved recipient info — set after successful lookup or prefill
|
// Resolved recipient info — set after successful lookup or prefill
|
||||||
private var resolvedAccountNumber = ""
|
private var resolvedAccountNumber: String
|
||||||
private var resolvedRecipientName = ""
|
get() = draft.resolvedAccountNumber
|
||||||
private var resolvedBankName = ""
|
set(value) { draft.resolvedAccountNumber = value }
|
||||||
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
private var resolvedRecipientName: String
|
||||||
private var resolvedToOwnAccount: BankAccount? = null
|
get() = draft.resolvedRecipientName
|
||||||
|
set(value) { draft.resolvedRecipientName = value }
|
||||||
|
private var resolvedBankName: String
|
||||||
|
get() = draft.resolvedBankName
|
||||||
|
set(value) { draft.resolvedBankName = value }
|
||||||
|
private var loadedToPhoto: Bitmap?
|
||||||
|
get() = draft.loadedToPhoto
|
||||||
|
set(value) { draft.loadedToPhoto = value }
|
||||||
|
private var resolvedDestCurrency: String
|
||||||
|
get() = draft.resolvedDestCurrency
|
||||||
|
set(value) { draft.resolvedDestCurrency = value }
|
||||||
|
private var resolvedToOwnAccount: BankAccount?
|
||||||
|
get() = draft.resolvedToOwnAccount
|
||||||
|
set(value) { draft.resolvedToOwnAccount = value }
|
||||||
|
private var savedToSubtitle: String
|
||||||
|
get() = draft.toSubtitle
|
||||||
|
set(value) { draft.toSubtitle = value }
|
||||||
|
private var savedToColorHex: String
|
||||||
|
get() = draft.toColorHex
|
||||||
|
set(value) { draft.toColorHex = value }
|
||||||
|
private var savedToImageHash: String?
|
||||||
|
get() = draft.toImageHash
|
||||||
|
set(value) { draft.toImageHash = value }
|
||||||
|
|
||||||
// Form state preserved across view destroy/create when the fragment instance is cached
|
/** Set when this view applied the fragment's arguments, so the accounts observer may too. */
|
||||||
private var savedAmount = ""
|
private var argsAppliedThisView = false
|
||||||
private var savedRemarks = ""
|
|
||||||
private var savedToText = ""
|
|
||||||
private var savedToSubtitle = ""
|
|
||||||
private var savedToColorHex = "#607D8B"
|
|
||||||
private var savedToImageHash: String? = null
|
|
||||||
|
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
||||||
|
private var contactDropdownAdapter: ContactDropdownAdapter? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
|
* Owns everything BML-specific: sessions, the transfer itself, the business-profile OTP
|
||||||
@@ -106,6 +137,7 @@ class TransferFragment : Fragment() {
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
currentSource = { selectedAccount },
|
currentSource = { selectedAccount },
|
||||||
selectSource = ::selectSourceAccount,
|
selectSource = ::selectSourceAccount,
|
||||||
|
clearSource = ::clearSourceAccount,
|
||||||
onStateChanged = { updateTransferButton() },
|
onStateChanged = { updateTransferButton() },
|
||||||
onTransferSuccess = { receipt, avatar ->
|
onTransferSuccess = { receipt, avatar ->
|
||||||
ReceiptStore.save(requireContext(), receipt)
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
@@ -172,13 +204,17 @@ class TransferFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return
|
if (result.resultCode != Activity.RESULT_OK) return
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
|
||||||
|
|
||||||
// BML card/gateway QR — hand off to dedicated payment screen
|
// BML Merchant Services payment link — resolve it to the QR its page would show
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
BmlMerchantTxnClient.parseTransactionId(raw)?.let {
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
binding.etTo.setText(it)
|
||||||
val fromCard = selectedAccount?.takeIf {
|
lookupBmlMerchantTransaction(it)
|
||||||
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
|
return
|
||||||
}
|
}
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber))
|
|
||||||
|
// BML card/gateway/POS QR — hand off to dedicated payment screen
|
||||||
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
|
if (bmlTarget != null) {
|
||||||
|
openBmlQr(bmlTarget)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +278,9 @@ class TransferFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
|
||||||
private const val ARG_BML_QR_URL = "bml_qr_url"
|
private const val ARG_BML_QR_URL = "bml_qr_url"
|
||||||
private const val ARG_AUTO_SCAN = "auto_scan"
|
private const val ARG_AUTO_SCAN = "auto_scan"
|
||||||
|
private const val ARG_BML_TXN_ID = "bml_txn_id"
|
||||||
|
/** Set once the arguments have been applied, so later views restore the draft instead. */
|
||||||
|
private const val ARG_APPLIED = "args_applied"
|
||||||
|
|
||||||
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
fun newInstanceWithAutoScan() = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
|
||||||
@@ -254,6 +293,11 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Opens on a BML Merchant Services transaction ID, which is resolved to its QR on load. */
|
||||||
|
fun newInstanceFromBmlTxn(transactionId: String) = TransferFragment().apply {
|
||||||
|
arguments = Bundle().apply { putString(ARG_BML_TXN_ID, transactionId) }
|
||||||
|
}
|
||||||
|
|
||||||
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
|
||||||
}
|
}
|
||||||
@@ -299,12 +343,25 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
// A screen opened with its own arguments (scanned QR, picked contact…) starts a fresh
|
||||||
|
// draft, once. Every later view — tab switch back, theme recreation — restores instead.
|
||||||
|
// The flag lives in the arguments so it survives the fragment being recreated too.
|
||||||
|
val args = arguments
|
||||||
|
argsAppliedThisView = args != null && !args.getBoolean(ARG_APPLIED, false)
|
||||||
|
if (argsAppliedThisView) {
|
||||||
|
viewModel.transferDraft = TransferDraft()
|
||||||
|
args!!.putBoolean(ARG_APPLIED, true)
|
||||||
|
}
|
||||||
|
|
||||||
qrLauncher = requireActivity().activityResultRegistry.register(
|
qrLauncher = requireActivity().activityResultRegistry.register(
|
||||||
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
|
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
|
||||||
) { onQrScanned(it) }
|
) { onQrScanned(it) }
|
||||||
|
|
||||||
setupFromDropdown()
|
setupFromDropdown()
|
||||||
setupAccountLookup()
|
setupAccountLookup()
|
||||||
|
// Contact search dropdown on the To field needs viewModel.contacts populated —
|
||||||
|
// otherwise it stays empty until the contacts tab or picker sheet is opened first.
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
accountDropdownAdapter?.notifyDataSetChanged()
|
accountDropdownAdapter?.notifyDataSetChanged()
|
||||||
@@ -327,7 +384,7 @@ class TransferFragment : Fragment() {
|
|||||||
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
||||||
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
|
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
|
||||||
if (selectedAccount?.bank == "MFAISA") {
|
if (selectedAccount?.bank == "MFAISA") {
|
||||||
binding.etTo.setText(accountNumber)
|
binding.etTo.setText(accountNumber, false)
|
||||||
mfaisaHandler().searchRecipient(accountNumber)
|
mfaisaHandler().searchRecipient(accountNumber)
|
||||||
return@setFragmentResultListener
|
return@setFragmentResultListener
|
||||||
}
|
}
|
||||||
@@ -336,18 +393,8 @@ class TransferFragment : Fragment() {
|
|||||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||||
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
||||||
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
||||||
if (selectedAccount == null) {
|
selectDefaultSourceIfNone()
|
||||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
focusAmount()
|
||||||
if (defaultNum != null) {
|
|
||||||
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
|
|
||||||
if (defaultAcc != null) {
|
|
||||||
selectedAccount = defaultAcc
|
|
||||||
updateAmountPrefix(defaultAcc)
|
|
||||||
showFromCard(defaultAcc)
|
|
||||||
updateTransferButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnPickContact.setOnClickListener {
|
binding.btnPickContact.setOnClickListener {
|
||||||
@@ -369,6 +416,19 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
binding.etAmount.addTextChangedListener { updateTransferButton() }
|
||||||
|
|
||||||
|
if (argsAppliedThisView) applyArguments()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewStateRestored(savedInstanceState: Bundle?) {
|
||||||
|
super.onViewStateRestored(savedInstanceState)
|
||||||
|
// Repaint here, not in onViewCreated: after a recreation the framework restores the
|
||||||
|
// EditTexts' old text in between, and that setText on the To field fires its "user
|
||||||
|
// edited the recipient" listener — which would hide a To card painted earlier.
|
||||||
|
if (!argsAppliedThisView) restoreFromDraft()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First view of a screen opened with arguments: prefill from them. */
|
||||||
|
private fun applyArguments() {
|
||||||
// Pre-select contact if navigated from contacts page or QR scan
|
// Pre-select contact if navigated from contacts page or QR scan
|
||||||
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
arguments?.getString(ARG_ACCOUNT)?.let { account ->
|
||||||
prefillToDirectly(
|
prefillToDirectly(
|
||||||
@@ -382,15 +442,36 @@ class TransferFragment : Fragment() {
|
|||||||
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
|
||||||
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
|
||||||
|
|
||||||
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
|
arguments?.getString(ARG_BML_QR_URL)?.let { openBmlQr(it) }
|
||||||
|
arguments?.getString(ARG_BML_TXN_ID)?.let {
|
||||||
|
// Shown in the To field so a failed lookup leaves the ID there to retry or correct.
|
||||||
|
binding.etTo.setText(it)
|
||||||
|
lookupBmlMerchantTransaction(it)
|
||||||
|
}
|
||||||
|
|
||||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||||
launchQrScanner()
|
launchQrScanner()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Restore form state when view is recreated on the cached no-args instance
|
/**
|
||||||
if (arguments == null) {
|
* Repaints a recreated view from [draft] — nothing is looked up again. The From card is
|
||||||
if (resolvedAccountNumber.isNotEmpty()) {
|
* repainted by the accounts observer; a BML QR lookup that never finished is retried there.
|
||||||
|
*/
|
||||||
|
private fun restoreFromDraft() {
|
||||||
|
// Amount first: a dynamic merchant QR overwrites and locks it below
|
||||||
|
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
|
||||||
|
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
|
||||||
|
val bmlQr = draft.bmlQrInfo
|
||||||
|
val bmlCardMerchant = draft.bmlCardMerchant
|
||||||
|
val mfaisaQr = draft.mfaisaQrInfo
|
||||||
|
val mfaisaRecipient = draft.mfaisaRecipient
|
||||||
|
when {
|
||||||
|
bmlCardMerchant != null -> bmlHandler().showCardMerchant(bmlCardMerchant)
|
||||||
|
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
|
||||||
|
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
|
||||||
|
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
|
||||||
|
resolvedAccountNumber.isNotEmpty() -> {
|
||||||
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||||
if (ownAccount != null) {
|
if (ownAccount != null) {
|
||||||
showToCard(ownAccount)
|
showToCard(ownAccount)
|
||||||
@@ -406,22 +487,40 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.GONE
|
binding.btnPickContact.visibility = View.GONE
|
||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
val photo = loadedToPhoto
|
||||||
} else if (savedToText.isNotEmpty()) {
|
if (photo != null) {
|
||||||
binding.etTo.setText(savedToText)
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(photo)
|
||||||
|
} else {
|
||||||
|
savedToImageHash?.let { loadToPhoto(it, isProfile = resolvedToOwnAccount != null) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
|
||||||
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
|
||||||
updateTransferButton()
|
|
||||||
}
|
}
|
||||||
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Restores the To-input row to its default state when a QR lookup fails. */
|
/**
|
||||||
internal fun resetToFieldVisibility() {
|
* Freezes the amount at a merchant's preset value. Unlike disabling the field this keeps it
|
||||||
binding.cardToInfo.visibility = View.GONE
|
* at full colour — it's the figure being paid, so it should read clearly — and a lock icon
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
* says why it can't be typed into.
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
*/
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
internal fun setAmountLocked(locked: Boolean) {
|
||||||
|
binding.etAmount.apply {
|
||||||
|
isFocusable = !locked
|
||||||
|
isFocusableInTouchMode = !locked
|
||||||
|
isCursorVisible = !locked
|
||||||
|
isLongClickable = !locked
|
||||||
|
if (locked) clearFocus()
|
||||||
|
}
|
||||||
|
binding.tilAmount.apply {
|
||||||
|
if (locked) {
|
||||||
|
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM
|
||||||
|
endIconDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_lock)
|
||||||
|
} else {
|
||||||
|
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun startLookupLoading() {
|
internal fun startLookupLoading() {
|
||||||
@@ -448,14 +547,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupFromDropdown() {
|
private fun setupFromDropdown() {
|
||||||
binding.btnClearFromInfo.setOnClickListener {
|
binding.btnClearFromInfo.setOnClickListener { clearSourceAccount() }
|
||||||
selectedAccount = null
|
|
||||||
binding.tilAmount.prefixText = null
|
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
|
||||||
binding.tilFrom.visibility = View.VISIBLE
|
|
||||||
binding.actvFrom.setText("", false)
|
|
||||||
updateTransferButton()
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
|
||||||
@@ -463,7 +555,7 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||||
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
|
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
|
||||||
if (bmlHandler().hasQrMerchant) {
|
if (bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) {
|
||||||
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
|
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
|
||||||
if (!isCard) {
|
if (!isCard) {
|
||||||
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
|
||||||
@@ -482,7 +574,7 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
|
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)?.takeIf { argsAppliedThisView }
|
||||||
if (fromNumber != null && selectedAccount == null) {
|
if (fromNumber != null && selectedAccount == null) {
|
||||||
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
@@ -494,7 +586,7 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
||||||
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
|
if (selectedAccount == null && argsAppliedThisView && arguments?.getString(ARG_ACCOUNT) != null) {
|
||||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
if (defaultNum != null) {
|
if (defaultNum != null) {
|
||||||
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
||||||
@@ -508,12 +600,9 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
||||||
// onViewCreated runs. Retry the lookup once sessions are available.
|
// onViewCreated runs; a lookup can also have been cut off by leaving the tab.
|
||||||
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
|
// Retry it once sessions are available.
|
||||||
if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) {
|
draft.pendingBmlQrTarget?.let { bmlHandler().lookupQrMerchant(it) }
|
||||||
val app = requireActivity().application as BasedBankApp
|
|
||||||
if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-render the from card when the view is recreated on a cached instance
|
// Re-render the from card when the view is recreated on a cached instance
|
||||||
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
||||||
@@ -530,28 +619,28 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
||||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||||
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
||||||
if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) {
|
if (resolvedAccountNumber.isNotBlank() && draft.mfaisaRecipient == null) {
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
resolvedToOwnAccount = null
|
resolvedToOwnAccount = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.etTo.setText("")
|
binding.etTo.setText("", false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
binding.tilTo.hint = getString(R.string.transfer_to)
|
binding.tilTo.hint = getString(R.string.transfer_to)
|
||||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||||
// Drop any M-Faisa-resolved recipient when switching banks
|
// Drop any M-Faisa-resolved recipient when switching banks
|
||||||
if (mfaisaHandler?.recipient != null) {
|
if (draft.mfaisaRecipient != null) {
|
||||||
mfaisaHandler?.clearState()
|
mfaisaHandler().clearState()
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
resolvedToOwnAccount = null
|
resolvedToOwnAccount = null
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.etTo.setText("")
|
binding.etTo.setText("", false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
|
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
|
||||||
@@ -715,22 +804,31 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearSourceAccount() {
|
||||||
|
selectedAccount = null
|
||||||
|
binding.tilAmount.prefixText = null
|
||||||
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
|
binding.actvFrom.setText("", false)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateAmountPrefix(account: BankAccount) {
|
private fun updateAmountPrefix(account: BankAccount) {
|
||||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupAccountLookup() {
|
private fun setupAccountLookup() {
|
||||||
binding.tilTo.setEndIconOnClickListener {
|
binding.tilTo.setEndIconOnClickListener { searchTo() }
|
||||||
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
binding.etTo.setOnEditorActionListener { _, actionId, _ ->
|
||||||
if (selectedAccount?.bank == "MFAISA") {
|
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||||
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
searchTo()
|
||||||
} else {
|
true
|
||||||
lookupAccount()
|
} else false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.btnClearToInfo.setOnClickListener {
|
binding.btnClearToInfo.setOnClickListener {
|
||||||
bmlHandler().clearQrMerchant()
|
bmlHandler().clearQrMerchant()
|
||||||
|
bmlHandler().clearCardMerchant()
|
||||||
mfaisaHandler().clearQrMerchant()
|
mfaisaHandler().clearQrMerchant()
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
@@ -764,6 +862,110 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupContactDropdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source.
|
||||||
|
* Done in place: reopening the screen for it rebuilt the whole form and made the To row
|
||||||
|
* vanish and reappear.
|
||||||
|
*/
|
||||||
|
private fun openBmlQr(bmlTarget: String) {
|
||||||
|
bmlHandler().lookupQrMerchant(bmlTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A BML Merchant Services transaction ID (or its payment link) typed into the To field: fetch
|
||||||
|
* the QR the payment page would show and pay it like a scanned one.
|
||||||
|
*/
|
||||||
|
private fun lookupBmlMerchantTransaction(transactionId: String) {
|
||||||
|
startLookupLoading()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
// Load the payment page first: it says whether the merchant takes BML Pay (QR flow)
|
||||||
|
// or only cards (Pomelo + 3-D Secure flow).
|
||||||
|
val page = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlMerchantTxnClient().fetchPayPage(transactionId) }.getOrNull()
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
|
||||||
|
if (page != null && !page.supportsBmlPay && page.supportsCard) {
|
||||||
|
stopLookupLoading()
|
||||||
|
bmlHandler().payCardMerchant(page)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// BML Pay (or unknown): resolve the QR and pay it like a scanned merchant QR.
|
||||||
|
val target = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
|
||||||
|
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
|
||||||
|
}
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
stopLookupLoading()
|
||||||
|
if (target == null) {
|
||||||
|
binding.tilTo.error = getString(R.string.transfer_bml_txn_lookup_failed)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
openBmlQr(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchTo() {
|
||||||
|
BmlMerchantTxnClient.parseTransactionId(binding.etTo.text?.toString().orEmpty())?.let {
|
||||||
|
lookupBmlMerchantTransaction(it)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
||||||
|
if (selectedAccount?.bank == "MFAISA") {
|
||||||
|
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
||||||
|
} else {
|
||||||
|
lookupAccount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves focus to the amount field and brings up the keyboard once a recipient is resolved. */
|
||||||
|
internal fun focusAmount() {
|
||||||
|
val et = binding.etAmount
|
||||||
|
// Posted so it runs after the To-row visibility changes and any closing picker sheet
|
||||||
|
et.post {
|
||||||
|
if (_binding == null) return@post
|
||||||
|
et.requestFocus()
|
||||||
|
et.setSelection(et.text?.length ?: 0)
|
||||||
|
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||||
|
imm.showSoftInput(et, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live "search contacts as you type" dropdown on the To field (shown once 3+ chars are entered). */
|
||||||
|
private fun setupContactDropdown() {
|
||||||
|
val adapter = ContactDropdownAdapter(requireContext()) { selectedAccount?.bank == "MFAISA" }
|
||||||
|
contactDropdownAdapter = adapter
|
||||||
|
binding.etTo.setAdapter(adapter)
|
||||||
|
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
adapter.updateSource(contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.etTo.setOnItemClickListener { _, _, position, _ ->
|
||||||
|
val contact = adapter.getContact(position) ?: return@setOnItemClickListener
|
||||||
|
prefillToDirectly(
|
||||||
|
accountNumber = contact.benefAccount,
|
||||||
|
displayName = contact.benefNickName,
|
||||||
|
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
colorHex = contact.bankColor,
|
||||||
|
imageHash = contact.customerImgHash
|
||||||
|
)
|
||||||
|
selectDefaultSourceIfNone()
|
||||||
|
focusAmount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks the user's default account as the source when a recipient is chosen before any source. */
|
||||||
|
private fun selectDefaultSourceIfNone() {
|
||||||
|
if (selectedAccount != null) return
|
||||||
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber() ?: return
|
||||||
|
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum } ?: return
|
||||||
|
selectSourceAccount(defaultAcc)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupAccount() {
|
private fun lookupAccount() {
|
||||||
@@ -801,6 +1003,7 @@ class TransferFragment : Fragment() {
|
|||||||
colorHex = matchedContact.bankColor,
|
colorHex = matchedContact.bankColor,
|
||||||
imageHash = matchedContact.customerImgHash
|
imageHash = matchedContact.customerImgHash
|
||||||
)
|
)
|
||||||
|
focusAmount()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +1071,8 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedRecipientName = info.accountName
|
resolvedRecipientName = info.accountName
|
||||||
resolvedBankName = info.bankId
|
resolvedBankName = info.bankId
|
||||||
resolvedDestCurrency = info.currency
|
resolvedDestCurrency = info.currency
|
||||||
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
|
savedToSubtitle = listOfNotNull(info.accountNumber, info.bankId, info.currency.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(" · ")
|
||||||
savedToColorHex = colorHex
|
savedToColorHex = colorHex
|
||||||
savedToImageHash = when {
|
savedToImageHash = when {
|
||||||
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
|
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
|
||||||
@@ -880,7 +1084,7 @@ class TransferFragment : Fragment() {
|
|||||||
showToCard(matchedAcc)
|
showToCard(matchedAcc)
|
||||||
} else {
|
} else {
|
||||||
binding.tvToAccountName.text = displayName
|
binding.tvToAccountName.text = displayName
|
||||||
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
|
binding.tvToBankBic.text = savedToSubtitle
|
||||||
binding.tvToAccountDetails.visibility = View.GONE
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
binding.tvToBalance.visibility = View.GONE
|
binding.tvToBalance.visibility = View.GONE
|
||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
@@ -892,6 +1096,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
saveToRecents(info)
|
saveToRecents(info)
|
||||||
|
focusAmount()
|
||||||
|
|
||||||
when {
|
when {
|
||||||
matchedAcc?.profileImageHash != null ->
|
matchedAcc?.profileImageHash != null ->
|
||||||
@@ -975,7 +1180,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
binding.etTo.setText(accountNumber)
|
binding.etTo.setText(accountNumber, false)
|
||||||
lookupAccount()
|
lookupAccount()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,6 +1261,12 @@ class TransferFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BML card-only merchant payment (no BML Pay) — verified card + 3-D Secure
|
||||||
|
if (bmlHandler().hasCardMerchant) {
|
||||||
|
bmlHandler().submitCardPayment()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val src = selectedAccount ?: run {
|
val src = selectedAccount ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
@@ -1114,6 +1325,8 @@ class TransferFragment : Fragment() {
|
|||||||
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
|
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
|
||||||
val bankNameCapture = resolvedBankName
|
val bankNameCapture = resolvedBankName
|
||||||
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
||||||
|
// The MIB receipt only takes a real photo; it draws its own initials placeholder otherwise
|
||||||
|
val capturedToPhoto = capturedToAvatar?.takeIf { it === loadedToPhoto }
|
||||||
|
|
||||||
val destCurrency = resolvedDestCurrency.ifBlank {
|
val destCurrency = resolvedDestCurrency.ifBlank {
|
||||||
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||||
@@ -1151,7 +1364,7 @@ class TransferFragment : Fragment() {
|
|||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToPhoto))
|
||||||
} else if (!ok) {
|
} else if (!ok) {
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
if (msg == "CONNECTIVITY") {
|
if (msg == "CONNECTIVITY") {
|
||||||
@@ -1338,6 +1551,10 @@ class TransferFragment : Fragment() {
|
|||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||||
dialog.setCancelable(false)
|
dialog.setCancelable(false)
|
||||||
|
// From here until the outcome is dismissed the payment is in flight: a theme/language
|
||||||
|
// change waits rather than recreating the screen out from under it.
|
||||||
|
val guard = (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner)
|
||||||
|
dialog.setOnDismissListener { guard?.end() }
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val spinner = CircularProgressDrawable(ctx).apply {
|
val spinner = CircularProgressDrawable(ctx).apply {
|
||||||
@@ -1478,7 +1695,7 @@ class TransferFragment : Fragment() {
|
|||||||
private fun updateTransferButton() {
|
private fun updateTransferButton() {
|
||||||
if (bmlHandler().isOtpFlowActive) return
|
if (bmlHandler().isOtpFlowActive) return
|
||||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||||
val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
|
||||||
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||||
@@ -1490,6 +1707,7 @@ class TransferFragment : Fragment() {
|
|||||||
internal fun clearForm() {
|
internal fun clearForm() {
|
||||||
bmlHandler().resetOtpState()
|
bmlHandler().resetOtpState()
|
||||||
bmlHandler().clearQrMerchant()
|
bmlHandler().clearQrMerchant()
|
||||||
|
bmlHandler().clearCardMerchant()
|
||||||
mfaisaHandler?.clearState()
|
mfaisaHandler?.clearState()
|
||||||
mfaisaHandler?.clearQrMerchant()
|
mfaisaHandler?.clearQrMerchant()
|
||||||
selectedAccount = null
|
selectedAccount = null
|
||||||
@@ -1498,6 +1716,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilFrom.visibility = View.VISIBLE
|
binding.tilFrom.visibility = View.VISIBLE
|
||||||
binding.tilAmount.prefixText = null
|
binding.tilAmount.prefixText = null
|
||||||
binding.tilAmount.isEnabled = true
|
binding.tilAmount.isEnabled = true
|
||||||
|
setAmountLocked(false)
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -1512,7 +1731,7 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.etTo.setText("")
|
binding.etTo.setText("", false)
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
binding.tilAmount.error = null
|
binding.tilAmount.error = null
|
||||||
}
|
}
|
||||||
@@ -1527,6 +1746,7 @@ class TransferFragment : Fragment() {
|
|||||||
if (_binding != null) {
|
if (_binding != null) {
|
||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
binding.ivToPhoto.setImageBitmap(bitmap)
|
binding.ivToPhoto.setImageBitmap(bitmap)
|
||||||
|
loadedToPhoto = bitmap
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1598,15 +1818,14 @@ class TransferFragment : Fragment() {
|
|||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
// Persist form state so it can be restored when the view is recreated
|
// Persist form state so it can be restored when the view is recreated
|
||||||
savedAmount = binding.etAmount.text?.toString() ?: ""
|
draft.amount = binding.etAmount.text?.toString() ?: ""
|
||||||
savedRemarks = binding.etRemarks.text?.toString() ?: ""
|
draft.remarks = binding.etRemarks.text?.toString() ?: ""
|
||||||
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
draft.toText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
||||||
// The bank handlers hold binding refs; drop them so the next view gets fresh ones.
|
// The bank handlers hold binding refs; drop them so the next view gets fresh ones. What
|
||||||
// Clearing also resets any in-progress OTP flow, which cannot sensibly resume.
|
// they resolved lives in the draft; an in-progress BML OTP flow cannot sensibly resume.
|
||||||
bmlHandler?.clearState()
|
bmlHandler?.clearState()
|
||||||
bmlHandler = null
|
bmlHandler = null
|
||||||
fahipayHandler = null
|
fahipayHandler = null
|
||||||
mfaisaHandler?.clearState()
|
|
||||||
mfaisaHandler = null
|
mfaisaHandler = null
|
||||||
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
|
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
|
||||||
qrLauncher = null
|
qrLauncher = null
|
||||||
@@ -1684,7 +1903,7 @@ class TransferFragment : Fragment() {
|
|||||||
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
||||||
b.root.alpha = when {
|
b.root.alpha = when {
|
||||||
inactive -> 0.4f
|
inactive -> 0.4f
|
||||||
bmlHandler().hasQrMerchant && !isCard -> 0.35f
|
(bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) && !isCard -> 0.35f
|
||||||
else -> 1f
|
else -> 1f
|
||||||
}
|
}
|
||||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||||
@@ -1782,4 +2001,59 @@ class TransferFragment : Fragment() {
|
|||||||
override fun convertResultToString(r: Any?) = ""
|
override fun convertResultToString(r: Any?) = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Filters [source] contacts by nickname/name/account number, shown once the query is 3+ chars. */
|
||||||
|
private inner class ContactDropdownAdapter(
|
||||||
|
private val context: Context,
|
||||||
|
private val isDisabled: () -> Boolean
|
||||||
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
private var source: List<BankContact> = emptyList()
|
||||||
|
private var filtered: List<BankContact> = emptyList()
|
||||||
|
|
||||||
|
fun updateSource(contacts: List<BankContact>) {
|
||||||
|
source = contacts
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContact(position: Int): BankContact? = filtered.getOrNull(position)
|
||||||
|
|
||||||
|
override fun getCount() = filtered.size
|
||||||
|
override fun getItem(position: Int) = filtered[position]
|
||||||
|
override fun getItemId(position: Int) = position.toLong()
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val contact = filtered[position]
|
||||||
|
val b = if (convertView?.tag is ItemPickerRowBinding) {
|
||||||
|
convertView.tag as ItemPickerRowBinding
|
||||||
|
} else {
|
||||||
|
ItemPickerRowBinding.inflate(LayoutInflater.from(context), parent, false).also { it.root.tag = it }
|
||||||
|
}
|
||||||
|
b.tvPrimary.text = contact.benefNickName
|
||||||
|
b.tvSecondary.text = "${contact.benefBankName} · ${contact.benefAccount}"
|
||||||
|
b.tvBalance.visibility = View.GONE
|
||||||
|
b.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
b.ivIcon.setImageBitmap(makeInitialsBitmap(contact.benefNickName, contact.bankColor))
|
||||||
|
return b.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||||
|
val query = constraint?.toString()?.trim().orEmpty()
|
||||||
|
val matches = if (isDisabled() || query.length < 3) emptyList() else source.filter {
|
||||||
|
it.benefNickName.contains(query, ignoreCase = true) ||
|
||||||
|
it.benefName.contains(query, ignoreCase = true) ||
|
||||||
|
it.benefAccount.contains(query, ignoreCase = true)
|
||||||
|
}.take(8)
|
||||||
|
return FilterResults().apply { values = matches; count = matches.size }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
override fun publishResults(constraint: CharSequence?, results: FilterResults?) {
|
||||||
|
filtered = results?.values as? List<BankContact> ?: emptyList()
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convertResultToString(r: Any?) = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ data class TransferReceiptData(
|
|||||||
// MIB receipt fields
|
// MIB receipt fields
|
||||||
val mibReferenceNo: String = "",
|
val mibReferenceNo: String = "",
|
||||||
val mibTransactionDate: String = "",
|
val mibTransactionDate: String = "",
|
||||||
|
val mibFromProfileName: String = "",
|
||||||
|
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
|
||||||
// BML receipt fields
|
// BML receipt fields
|
||||||
val bmlFromName: String = "",
|
val bmlFromName: String = "",
|
||||||
val bmlReference: String = "",
|
val bmlReference: String = "",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import android.widget.Toast
|
|||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
@@ -33,6 +34,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
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.FragmentReceiptBmlBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||||
@@ -61,6 +64,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS = "remarks"
|
private const val ARG_REMARKS = "remarks"
|
||||||
private const val ARG_MIB_REF = "mib_ref"
|
private const val ARG_MIB_REF = "mib_ref"
|
||||||
private const val ARG_MIB_DATE = "mib_date"
|
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_FROM_NAME = "bml_from_name"
|
||||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||||
@@ -89,6 +94,8 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
putString(ARG_REMARKS, data.remarks)
|
putString(ARG_REMARKS, data.remarks)
|
||||||
putString(ARG_MIB_REF, data.mibReferenceNo)
|
putString(ARG_MIB_REF, data.mibReferenceNo)
|
||||||
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
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_FROM_NAME, data.bmlFromName)
|
||||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||||
@@ -161,6 +168,15 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||||
saveReceipt()
|
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 ──────────────────────────────────────────────────────────
|
// ── Data binding ──────────────────────────────────────────────────────────
|
||||||
@@ -168,43 +184,64 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
||||||
val args = requireArguments()
|
val args = requireArguments()
|
||||||
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
||||||
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
|
|
||||||
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
||||||
val toLabel = args.getString(ARG_TO_LABEL, "")
|
val toLabel = args.getString(ARG_TO_LABEL, "")
|
||||||
val currency = args.getString(ARG_CURRENCY, "MVR")
|
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||||
val amount = args.getString(ARG_AMOUNT, "")
|
val amount = args.getString(ARG_AMOUNT, "")
|
||||||
|
|
||||||
// From avatar: initials first, then load profile image if hash available
|
// 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
|
binding.tvFromLabel.text = fromLabel
|
||||||
if (fromProfileHash != null) {
|
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
|
// To avatar: use already-rendered bitmap from TransferFragment if available
|
||||||
val toAvatar = pendingToAvatarBitmap
|
val toAvatar = pendingToAvatarBitmap
|
||||||
if (toAvatar != null) {
|
if (toAvatar != null) {
|
||||||
binding.ivToAvatar.setImageBitmap(toAvatar)
|
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar))
|
||||||
} else {
|
} else {
|
||||||
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
|
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel))
|
||||||
}
|
}
|
||||||
binding.tvToLabel.text = toLabel
|
binding.tvToLabel.text = toLabel
|
||||||
|
|
||||||
binding.tvAmount.text = "$currency $amount"
|
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.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
||||||
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
|
binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel }
|
||||||
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
|
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, ""))
|
||||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
.filter { it.isNotBlank() }.joinToString("\n")
|
||||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
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, "")
|
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||||
|
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
|
||||||
|
|
||||||
copyOnLongClick(
|
copyOnLongClick(
|
||||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus,
|
||||||
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank,
|
||||||
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
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) {
|
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.anyMibSession() ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
@@ -375,8 +412,13 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||||
*/
|
*/
|
||||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||||
val view = _receiptCard ?: run { callback(null); return }
|
val shown = _receiptCard ?: run { callback(null); return }
|
||||||
if (view.width == 0 || view.height == 0) { 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 bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bitmap)
|
val canvas = Canvas(bitmap)
|
||||||
@@ -385,6 +427,27 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
callback(bitmap)
|
callback(bitmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
private fun formatBmlTimestamp(raw: String): String {
|
||||||
if (raw.isBlank()) return ""
|
if (raw.isBlank()) return ""
|
||||||
return try {
|
return try {
|
||||||
@@ -394,26 +457,46 @@ 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 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 bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
paint.color = bgColor
|
paint.color = Color.parseColor("#C6E1FD")
|
||||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
paint.color = Color.WHITE
|
paint.color = Color.parseColor("#1168F3")
|
||||||
paint.textSize = sizePx * 0.42f
|
paint.textSize = sizePx * 0.36f
|
||||||
paint.textAlign = Paint.Align.CENTER
|
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
|
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
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFullScreenReceipt() {
|
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
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 dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
|
||||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||||
@@ -455,6 +538,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||||
|
if (closePageOnDismiss) closeReceiptPage()
|
||||||
}
|
}
|
||||||
dialog.show()
|
dialog.show()
|
||||||
dialog.window?.let { win ->
|
dialog.window?.let { win ->
|
||||||
@@ -466,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) {
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
for (tv in views) {
|
for (tv in views) {
|
||||||
tv.setOnLongClickListener {
|
tv.setOnLongClickListener {
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
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.BmlOtpChannel
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
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.BmlQrPayResult
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
@@ -31,6 +33,7 @@ import sh.sar.basedbank.ui.home.HomeViewModel
|
|||||||
import sh.sar.basedbank.ui.home.TransferFragment
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import sh.sar.basedbank.util.RecentPick
|
import sh.sar.basedbank.util.RecentPick
|
||||||
import sh.sar.basedbank.util.RecentsCache
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
@@ -57,6 +60,8 @@ class BmlTransferHandler(
|
|||||||
private val currentSource: () -> BankAccount?,
|
private val currentSource: () -> BankAccount?,
|
||||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
private val selectSource: (BankAccount) -> Unit,
|
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. */
|
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||||
private val onStateChanged: () -> Unit,
|
private val onStateChanged: () -> Unit,
|
||||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||||
@@ -72,6 +77,18 @@ class BmlTransferHandler(
|
|||||||
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||||
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
private var otpState = OtpState.NONE
|
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 var otpChannel: String? = null
|
||||||
|
|
||||||
private data class PendingTransfer(
|
private data class PendingTransfer(
|
||||||
@@ -91,14 +108,15 @@ class BmlTransferHandler(
|
|||||||
)
|
)
|
||||||
private var pendingTransfer: PendingTransfer? = null
|
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). */
|
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||||
var qrInfo: BmlQrPayInfo? = null
|
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
|
||||||
private set
|
|
||||||
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||||
private var gatewayQr = false
|
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
|
||||||
/** Prevents re-running the lookup after the user clears the merchant. */
|
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
|
||||||
var qrLookupAttempted = false
|
private var qrLookupInFlight = false
|
||||||
private set
|
|
||||||
|
|
||||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,10 +171,11 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
fun clearQrMerchant() {
|
fun clearQrMerchant() {
|
||||||
|
draft.pendingBmlQrTarget = null
|
||||||
if (qrInfo == null) return
|
if (qrInfo == null) return
|
||||||
qrInfo = null
|
draft.bmlQrInfo = null
|
||||||
gatewayQr = false
|
draft.bmlGatewayQr = false
|
||||||
binding.tilAmount.isEnabled = true
|
fragment.setAmountLocked(false)
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -171,31 +190,58 @@ class BmlTransferHandler(
|
|||||||
|
|
||||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
// ─── 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) {
|
fun lookupQrMerchant(qrUrl: String) {
|
||||||
qrLookupAttempted = true
|
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
|
||||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
val draft = this.draft
|
||||||
val base64Url = android.util.Base64.encodeToString(
|
draft.pendingBmlQrTarget = qrUrl
|
||||||
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
if (qrLookupInFlight) return
|
||||||
val session = app.anyBmlSession() ?: return
|
val session = app.anyBmlSession() ?: return
|
||||||
|
qrLookupInFlight = true
|
||||||
|
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||||
|
|
||||||
// Lock the "To" input row while loading
|
// The To row stays on screen with a spinner while loading and is only swapped for the
|
||||||
binding.tilTo.visibility = View.GONE
|
// merchant card once there is a merchant to show — hiding it up front left a gap that
|
||||||
binding.btnPickContact.visibility = View.GONE
|
// made the form jump twice.
|
||||||
binding.btnScanQr.visibility = View.GONE
|
fragment.startLookupLoading()
|
||||||
host?.setRefreshing(true)
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||||
catch (_: Exception) { null }
|
|
||||||
}
|
}
|
||||||
host?.setRefreshing(false)
|
host?.setRefreshing(false)
|
||||||
if (info == null) {
|
qrLookupInFlight = false
|
||||||
Toast.makeText(ctx, R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
if (fragment.view == null) return@launch
|
||||||
fragment.requireActivity().onBackPressedDispatcher.onBackPressed()
|
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
|
return@launch
|
||||||
}
|
}
|
||||||
qrInfo = info
|
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) {
|
if (info.amount == 0.0) {
|
||||||
RecentsCache.save(ctx, RecentPick(
|
RecentsCache.save(ctx, RecentPick(
|
||||||
accountNumber = "bmlqr:$qrUrl",
|
accountNumber = "bmlqr:$qrUrl",
|
||||||
@@ -207,7 +253,13 @@ class BmlTransferHandler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select the user's default BML card if no card was pre-selected
|
// 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) {
|
if (currentSource() == null) {
|
||||||
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||||
if (defaultNum != null) {
|
if (defaultNum != null) {
|
||||||
@@ -220,29 +272,44 @@ class BmlTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
showQrMerchant(info)
|
||||||
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))
|
|
||||||
binding.tilAmount.isEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remarks not applicable for merchant QR payments
|
|
||||||
binding.tilRemarks.isEnabled = false
|
|
||||||
binding.tilRemarks.alpha = 0.4f
|
|
||||||
|
|
||||||
onStateChanged()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* 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.
|
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||||
@@ -351,6 +418,176 @@ class BmlTransferHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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> {
|
||||||
|
val store = CredentialStore(ctx)
|
||||||
|
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
|
||||||
|
return (viewModel.accounts.value ?: emptyList())
|
||||||
|
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
|
||||||
|
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 =
|
||||||
|
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
|
||||||
|
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private 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 stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
val expiry = stored?.expiry?.split("/") // "MM/YY"
|
||||||
|
if (stored == null || otpSeed == null || expiry?.size != 2) {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
|
||||||
|
pan = stored.pan,
|
||||||
|
expiryMonth = expiry[0].padStart(2, '0'),
|
||||||
|
expiryYear = expiry[1].takeLast(2),
|
||||||
|
cvv = stored.cvv,
|
||||||
|
holderName = src.accountBriefName
|
||||||
|
)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
BmlMerchantCardPayClient().pay(page, card) { _ -> 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()
|
||||||
|
}
|
||||||
|
is BmlMerchantCardPayClient.Result.Failure -> {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ class FahipayTransferHandler(
|
|||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
/** The service picked for the current recipient; null until a lookup resolves one. */
|
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||||
var service: FahipayService? = null
|
var service: FahipayService?
|
||||||
private set
|
get() = viewModel.transferDraft.fahipayService
|
||||||
|
private set(value) { viewModel.transferDraft.fahipayService = value }
|
||||||
|
|
||||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||||
@@ -194,5 +195,6 @@ class FahipayTransferHandler(
|
|||||||
colorHex = "#FF6B00",
|
colorHex = "#FF6B00",
|
||||||
imageHash = null
|
imageHash = null
|
||||||
)
|
)
|
||||||
|
fragment.focusAmount()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,16 +68,29 @@ class MfaisaTransferHandler(
|
|||||||
private val ctx get() = fragment.requireContext()
|
private val ctx get() = fragment.requireContext()
|
||||||
private val host get() = fragment.activity as? HomeActivity
|
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. */
|
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||||
var recipient: MfaisaTransferClient.Recipient? = null
|
var recipient: MfaisaTransferClient.Recipient?
|
||||||
private set
|
get() = draft.mfaisaRecipient
|
||||||
|
private set(value) { draft.mfaisaRecipient = value }
|
||||||
|
|
||||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||||
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
var qrInfo: MfaisaQrPayClient.QrMerchant?
|
||||||
private set
|
get() = draft.mfaisaQrInfo
|
||||||
|
private set(value) { draft.mfaisaQrInfo = value }
|
||||||
|
|
||||||
private var lookupInFlight = false
|
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 ───────────────────────────────────────
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||||
@@ -121,6 +134,7 @@ class MfaisaTransferHandler(
|
|||||||
} else {
|
} else {
|
||||||
recipient = result
|
recipient = result
|
||||||
showResolvedRecipient(result)
|
showResolvedRecipient(result)
|
||||||
|
fragment.focusAmount()
|
||||||
}
|
}
|
||||||
} catch (_: MfaisaRecipientNotFoundException) {
|
} catch (_: MfaisaRecipientNotFoundException) {
|
||||||
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||||
@@ -153,6 +167,8 @@ class MfaisaTransferHandler(
|
|||||||
|
|
||||||
binding.btnTransfer.isEnabled = false
|
binding.btnTransfer.isEnabled = false
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
endTransferFlow()
|
||||||
|
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val refId = try {
|
val refId = try {
|
||||||
@@ -160,6 +176,7 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
showError(e)
|
showError(e)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -182,7 +199,7 @@ class MfaisaTransferHandler(
|
|||||||
fun clearQrMerchant() {
|
fun clearQrMerchant() {
|
||||||
if (qrInfo == null) return
|
if (qrInfo == null) return
|
||||||
qrInfo = null
|
qrInfo = null
|
||||||
binding.tilAmount.isEnabled = true
|
fragment.setAmountLocked(false)
|
||||||
binding.tilRemarks.isEnabled = true
|
binding.tilRemarks.isEnabled = true
|
||||||
binding.tilRemarks.alpha = 1f
|
binding.tilRemarks.alpha = 1f
|
||||||
binding.etAmount.setText("")
|
binding.etAmount.setText("")
|
||||||
@@ -208,10 +225,8 @@ class MfaisaTransferHandler(
|
|||||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||||
|
|
||||||
// Lock the "To" input row while loading
|
// The To row stays up with a spinner until there is a merchant to swap in
|
||||||
binding.tilTo.visibility = View.GONE
|
fragment.startLookupLoading()
|
||||||
binding.btnPickContact.visibility = View.GONE
|
|
||||||
binding.btnScanQr.visibility = View.GONE
|
|
||||||
host?.setRefreshing(true)
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -226,9 +241,10 @@ class MfaisaTransferHandler(
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
host?.setRefreshing(false)
|
host?.setRefreshing(false)
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
fragment.stopLookupLoading()
|
||||||
if (merchant == null) {
|
if (merchant == null) {
|
||||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||||
fragment.resetToFieldVisibility()
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
qrInfo = merchant
|
qrInfo = merchant
|
||||||
@@ -247,26 +263,37 @@ class MfaisaTransferHandler(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show merchant in the "To" card — clear button is the only way to back out
|
showQrMerchant(merchant)
|
||||||
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))
|
|
||||||
binding.tilAmount.isEnabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onRecipientChanged()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 —
|
* 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
|
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||||
@@ -396,7 +423,8 @@ class MfaisaTransferHandler(
|
|||||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||||
|
|
||||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
/** 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
|
// 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.
|
// card view, so we just populate its text fields and toggle visibility.
|
||||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||||
@@ -412,7 +440,7 @@ class MfaisaTransferHandler(
|
|||||||
binding.btnScanQr.visibility = View.GONE
|
binding.btnScanQr.visibility = View.GONE
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
RecentsCache.save(ctx, RecentPick(
|
if (saveRecent) RecentsCache.save(ctx, RecentPick(
|
||||||
accountNumber = r.msisdn,
|
accountNumber = r.msisdn,
|
||||||
displayName = r.name.ifBlank { r.msisdn },
|
displayName = r.name.ifBlank { r.msisdn },
|
||||||
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||||
@@ -469,7 +497,7 @@ class MfaisaTransferHandler(
|
|||||||
refId: String,
|
refId: String,
|
||||||
errorMsg: String?
|
errorMsg: String?
|
||||||
) {
|
) {
|
||||||
val view = fragment.view ?: return
|
val view = fragment.view ?: run { endTransferFlow(); return }
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val colorMuted = MaterialColors.getColor(
|
val colorMuted = MaterialColors.getColor(
|
||||||
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||||
@@ -558,6 +586,7 @@ class MfaisaTransferHandler(
|
|||||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||||
d.dismiss()
|
d.dismiss()
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
.show()
|
.show()
|
||||||
@@ -582,6 +611,7 @@ class MfaisaTransferHandler(
|
|||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
endTransferFlow()
|
||||||
val receipt = TransferReceiptData(
|
val receipt = TransferReceiptData(
|
||||||
bank = "MFAISA",
|
bank = "MFAISA",
|
||||||
amount = amountValue,
|
amount = amountValue,
|
||||||
@@ -606,6 +636,7 @@ class MfaisaTransferHandler(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
showError(e)
|
showError(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ class MibTransferHandler(
|
|||||||
else -> bankName.ifBlank { "LOCAL" }
|
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 {
|
return try {
|
||||||
// Switch to the profile that owns the source account
|
// Switch to the profile that owns the source account
|
||||||
if (src.profileId.isNotBlank()) {
|
if (src.profileId.isNotBlank()) {
|
||||||
@@ -160,7 +169,9 @@ class MibTransferHandler(
|
|||||||
toBank = toBank,
|
toBank = toBank,
|
||||||
remarks = remarks,
|
remarks = remarks,
|
||||||
mibReferenceNo = result.trxId,
|
mibReferenceNo = result.trxId,
|
||||||
mibTransactionDate = result.date
|
mibTransactionDate = result.date,
|
||||||
|
mibFromProfileName = src.profileName,
|
||||||
|
mibTransactionType = transactionType
|
||||||
)
|
)
|
||||||
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// Fahipay
|
||||||
|
var fahipayService: FahipayService? = null
|
||||||
|
}
|
||||||
@@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() {
|
|||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.cardOtp.setOnClickListener {
|
binding.cardOtp.root.setOnClickListener {
|
||||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
|
||||||
if (code.isNotEmpty()) {
|
if (code.isNotEmpty()) {
|
||||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
@@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() {
|
|||||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
val seed = resolveOtpSeed(otpSeedRaw)
|
val seed = resolveOtpSeed(otpSeedRaw)
|
||||||
if (seed.isEmpty()) {
|
if (seed.isEmpty()) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -211,12 +211,13 @@ class CredentialsFragment : Fragment() {
|
|||||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
|
|
||||||
binding.tvOtpCode.text = otp
|
binding.cardOtp.tvOtpCode.text = otp
|
||||||
binding.otpTimer.max = 30
|
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||||
binding.otpTimer.progress = remaining
|
binding.cardOtp.otpTimer.max = 30
|
||||||
binding.cardOtp.visibility = View.VISIBLE
|
binding.cardOtp.otpTimer.progress = remaining
|
||||||
|
binding.cardOtp.root.visibility = View.VISIBLE
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ data class ContactDisplay(
|
|||||||
val network: TransferNetwork,
|
val network: TransferNetwork,
|
||||||
val bankColor: String,
|
val bankColor: String,
|
||||||
val detail: String?, // pre-formatted "Name · CCY · Bank" line; null = hide row
|
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 imageHash: String?,
|
||||||
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
val profileId: String, // MIB profile ID or BML loginTag (needed by ContactManager)
|
||||||
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
val transferSubtitle: String, // "Bank · accountNumber" shown in transfer screen
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import javax.crypto.spec.GCMParameterSpec
|
|||||||
|
|
||||||
class CredentialStore(context: Context) {
|
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 prefs = context.getSharedPreferences("credential_store", Context.MODE_PRIVATE)
|
||||||
private val keyAlias = "basedbank_credential_key"
|
private val keyAlias = "basedbank_credential_key"
|
||||||
private val transformation = "AES/GCM/NoPadding"
|
private val transformation = "AES/GCM/NoPadding"
|
||||||
@@ -23,6 +28,37 @@ class CredentialStore(context: Context) {
|
|||||||
data class FahipayCredentials(val idCard: String, val password: String)
|
data class FahipayCredentials(val idCard: String, val password: String)
|
||||||
data class MfaisaCredentials(val msisdn: String, val pin: 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) ─────
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
|
|
||||||
fun getMibLoginIds(): List<String> {
|
fun getMibLoginIds(): List<String> {
|
||||||
@@ -36,6 +72,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
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) {
|
private fun addMibLoginId(loginId: String) {
|
||||||
val ids = getMibLoginIds().toMutableList()
|
val ids = getMibLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -59,6 +100,11 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.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? {
|
fun loadMibCredentials(loginId: String): MibCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
|
||||||
@@ -160,6 +206,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
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) {
|
private fun addBmlLoginId(loginId: String) {
|
||||||
val ids = getBmlLoginIds().toMutableList()
|
val ids = getBmlLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -184,6 +235,11 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.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? {
|
fun loadBmlCredentials(loginId: String): BmlCredentials? {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
|
||||||
@@ -316,6 +372,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
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) {
|
private fun addFahipayLoginId(loginId: String) {
|
||||||
val ids = getFahipayLoginIds().toMutableList()
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -473,6 +534,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
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) {
|
private fun addMfaisaLoginId(loginId: String) {
|
||||||
val ids = getMfaisaLoginIds().toMutableList()
|
val ids = getMfaisaLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -764,6 +830,15 @@ class CredentialStore(context: Context) {
|
|||||||
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
fun setHiddenMibProfileIds(loginId: String, ids: Set<String>) =
|
||||||
prefs.edit().putStringSet("mib_${loginId}_hidden_profile_ids", ids).apply()
|
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 ─────────────────────────────────────────────────────
|
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun getOrCreateKey(): SecretKey {
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
|||||||
@@ -13,6 +13,27 @@ object OtpauthParser {
|
|||||||
else -> emptyList()
|
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? {
|
private fun parseStandard(raw: String): OtpEntry? {
|
||||||
val uri = Uri.parse(raw)
|
val uri = Uri.parse(raw)
|
||||||
val secret = uri.getQueryParameter("secret") ?: return null
|
val secret = uri.getQueryParameter("secret") ?: return null
|
||||||
|
|||||||
@@ -9,22 +9,58 @@ data class PaymvQrData(
|
|||||||
|
|
||||||
object PaymvQrParser {
|
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.
|
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||||
* 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)
|
* Three shapes exist:
|
||||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
* - 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? {
|
fun bmlQrPayTarget(raw: String): String? {
|
||||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||||
return try {
|
val ref = bmlQrReference(raw) ?: return null
|
||||||
val root = parseTlv(raw)
|
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||||
} catch (_: Exception) {
|
}
|
||||||
null
|
|
||||||
}
|
/** 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? {
|
fun parse(raw: String): PaymvQrData? {
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ object ReceiptStore {
|
|||||||
remarks = o.optString("remarks"),
|
remarks = o.optString("remarks"),
|
||||||
mibReferenceNo = o.optString("mibReferenceNo"),
|
mibReferenceNo = o.optString("mibReferenceNo"),
|
||||||
mibTransactionDate = o.optString("mibTransactionDate"),
|
mibTransactionDate = o.optString("mibTransactionDate"),
|
||||||
|
mibFromProfileName = o.optString("mibFromProfileName"),
|
||||||
|
mibTransactionType = o.optString("mibTransactionType"),
|
||||||
bmlFromName = o.optString("bmlFromName"),
|
bmlFromName = o.optString("bmlFromName"),
|
||||||
bmlReference = o.optString("bmlReference"),
|
bmlReference = o.optString("bmlReference"),
|
||||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||||
@@ -76,6 +78,8 @@ object ReceiptStore {
|
|||||||
put("remarks", d.remarks)
|
put("remarks", d.remarks)
|
||||||
put("mibReferenceNo", d.mibReferenceNo)
|
put("mibReferenceNo", d.mibReferenceNo)
|
||||||
put("mibTransactionDate", d.mibTransactionDate)
|
put("mibTransactionDate", d.mibTransactionDate)
|
||||||
|
put("mibFromProfileName", d.mibFromProfileName)
|
||||||
|
put("mibTransactionType", d.mibTransactionType)
|
||||||
put("bmlFromName", d.bmlFromName)
|
put("bmlFromName", d.bmlFromName)
|
||||||
put("bmlReference", d.bmlReference)
|
put("bmlReference", d.bmlReference)
|
||||||
put("bmlTimestamp", d.bmlTimestamp)
|
put("bmlTimestamp", d.bmlTimestamp)
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ object Totp {
|
|||||||
/**
|
/**
|
||||||
* Generate a 6-digit TOTP code from a Base32-encoded secret (RFC 6238 / RFC 4226).
|
* 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.
|
* 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 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)
|
val otp = hotp(key, counter, digits)
|
||||||
return otp.toString().padStart(digits, '0')
|
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)
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ object BmlContactParser {
|
|||||||
network = TransferNetwork.BML,
|
network = TransferNetwork.BML,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ object FahipayContactParser {
|
|||||||
network = TransferNetwork.FAHIPAY,
|
network = TransferNetwork.FAHIPAY,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = null, // Fahipay contacts show no detail line
|
detail = null, // Fahipay contacts show no detail line
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = contact.benefAccount,
|
transferSubtitle = contact.benefAccount,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ object MibContactParser {
|
|||||||
network = network,
|
network = network,
|
||||||
bankColor = contact.bankColor,
|
bankColor = contact.bankColor,
|
||||||
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
detail = "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}",
|
||||||
|
bankName = contact.benefBankName.takeIf { it.isNotBlank() },
|
||||||
|
currency = contact.transferCyDesc.takeIf { it.isNotBlank() },
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
profileId = contact.profileId,
|
profileId = contact.profileId,
|
||||||
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
transferSubtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material "credit_score": card with a check mark -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h5v-2H4v-6h18V6C22,4.89 21.11,4 20,4zM20,8H4V6h16V8zM14.93,19.17l-2.83,-2.83l-1.41,1.41L14.93,22L22,14.93l-1.41,-1.41L14.93,19.17z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M15,4 L7,12 L15,20" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/black"
|
||||||
|
android:pathData="M11.8,10.9c-2.27,-0.59 -3,-1.2 -3,-2.15 0,-1.09 1.01,-1.85 2.7,-1.85 1.78,0 2.44,0.85 2.5,2.1h2.21c-0.07,-1.72 -1.12,-3.3 -3.21,-3.81V3h-3v2.16c-1.94,0.42 -3.5,1.68 -3.5,3.61 0,2.31 1.91,3.46 4.7,4.13 2.5,0.6 3,1.48 3,2.41 0,0.69 -0.49,1.79 -2.7,1.79 -2.06,0 -2.87,-0.92 -2.98,-2.1h-2.2c0.12,2.19 1.76,3.42 3.68,3.83V21h3v-2.15c1.95,-0.37 3.5,-1.5 3.5,-3.55 0,-2.84 -2.43,-3.61 -4.7,-4.2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M20,5L4,5c-1.1,0 -1.99,0.9 -1.99,2L2,17c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,7c0,-1.1 -0.9,-2 -2,-2zM11,8h2v2h-2L11,8zM11,11h2v2h-2v-2zM8,8h2v2L8,10L8,8zM8,11h2v2L8,13v-2zM7,13L5,13v-2h2v2zM7,10L5,10L5,8h2v2zM16,17L8,17v-2h8v2zM16,13h-2v-2h2v2zM16,10h-2L14,8h2v2zM19,13h-2v-2h2v2zM19,10h-2L17,8h2v2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- MIB full-screen receipt close button: tinted-black disc, themed ring, white X -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="32dp"
|
||||||
|
android:height="32dp"
|
||||||
|
android:viewportWidth="32"
|
||||||
|
android:viewportHeight="32">
|
||||||
|
<path
|
||||||
|
android:fillColor="#4D000000"
|
||||||
|
android:strokeColor="@color/mib_receipt_close_ring"
|
||||||
|
android:strokeWidth="1.5"
|
||||||
|
android:pathData="M16,1.25 A14.75,14.75 0 1,1 16,30.75 A14.75,14.75 0 1,1 16,1.25 Z" />
|
||||||
|
<path
|
||||||
|
android:strokeColor="#FFFFFFFF"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M11,11 L21,21 M21,11 L11,21" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/black"
|
||||||
|
android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5zM3,21h8v-8H3V21zM5,15h4v4H5V15zM13,3v8h8V3H13zM19,9h-4V5h4V9zM19,19h2v2h-2V19zM13,13h2v2h-2V13zM15,15h2v2h-2V15zM13,17h2v2h-2V17zM15,19h2v2h-2V19zM17,17h2v2h-2V17zM17,13h2v2h-2V13zM19,15h2v2h-2V15z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M4,7h16v2H4V7zM4,11h16v2H4v-2zM4,15h16v2H4v-2z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Full MIB logo (mark + "MALDIVES ISLAMIC BANK" wordmark); wordmark follows the receipt footer text color -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="187.6dp"
|
||||||
|
android:height="22dp"
|
||||||
|
android:viewportWidth="779.5"
|
||||||
|
android:viewportHeight="91.4">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#A8AAAC"
|
||||||
|
android:strokeWidth="3.8833"
|
||||||
|
android:strokeMiterLimit="10"
|
||||||
|
android:pathData="M64.6,89.2H26.7c-13.5,0-24.5-11-24.5-24.5v-38c0-13.5,11-24.5,24.5-24.5h37.9c13.5,0,24.5,11,24.5,24.5v37.9 C89.1,78.2,78.2,89.2,64.6,89.2z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#1E2859"
|
||||||
|
android:pathData="M49.4,28.7c1.7-1.7,3.5-3.3,5.4-4.8c1.9,1.5,3.7,3.1,5.4,4.8c9,9.2,14.4,21.7,14.4,35.3c0,0.7,0,1.5-0.1,2.2 H57.6l10.9-6.8c-0.5-6-2.4-11.8-5.6-17.2c-2.2-3.7-5-7-8.2-9.7c-3.2,2.7-5.9,6-8.2,9.7c-3.2,5.3-5,11.2-5.6,17.2l4.6,6.8H35 c0-0.7-0.1-1.5-0.1-2.2C34.9,50.3,40.4,37.9,49.4,28.7z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#006A4D"
|
||||||
|
android:pathData="M41.9,28.7c-1.7-1.7-3.5-3.3-5.4-4.8c-1.9,1.5-3.7,3.1-5.4,4.8c-9,9.2-14.4,21.7-14.4,35.3 c0,0.7,0,1.5,0.1,2.2h16.9l-10.9-6.8c0.5-6,2.4-11.8,5.6-17.2c2.2-3.7,5-7,8.2-9.7c3.2,2.7,5.9,6,8.2,9.7c3.2,5.3,5,11.2,5.6,17.2 l-4.6,6.8h10.6c0-0.7,0.1-1.5,0.1-2.2C56.3,50.3,50.8,37.9,41.9,28.7z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M146.4,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L146.4,49.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M170,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8L183,54.6H170z M181.7,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L171.2,49H181.7z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M200.5,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M230.5,25.8c3.3-0.5,7.5-0.9,11.9-0.9c7.7,0,13,1.6,16.7,4.7c4,3.2,6.4,8.1,6.4,15.1c0,7.3-2.5,12.8-6.4,16.3 c-4.1,3.7-10.6,5.6-18.6,5.6c-4.4,0-7.7-0.2-10.1-0.5V25.8z M237.9,60.5c1,0.2,2.6,0.2,4.1,0.2c9.7,0.1,15.5-5.3,15.5-15.7 c0.1-9.1-5.2-14.2-14.5-14.2c-2.4,0-4.1,0.2-5.1,0.4V60.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M279.5,25.2v41H272v-41H279.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M297.4,66.2l-13.3-41h8.2l5.6,18.6c1.6,5.2,2.9,10,4,15h0.1c1.1-4.9,2.6-9.9,4.2-14.8l6-18.7h8l-14.2,41 H297.4z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M347.6,48.1h-15.5v12h17.3v6.1h-24.8v-41h23.8v6.1h-16.4V42h15.5V48.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M355.8,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L355.8,58.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M407.6,25.2v41h-7.5v-41H407.6z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M416,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L416,58.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M447.8,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M486.2,54.6l-3.5,11.6H475l13.1-41h9.6l13.3,41h-8l-3.7-11.6H486.2z M498,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L487.4,49H498z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M551.1,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L551.1,49.5z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M574.3,25.2v41h-7.5v-41H574.3z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M612.3,65c-1.8,0.9-5.7,1.8-10.6,1.8c-13,0-20.9-8.2-20.9-20.6c0-13.5,9.4-21.7,21.9-21.7c4.9,0,8.5,1,10,1.8 l-1.6,6c-1.9-0.9-4.6-1.6-8-1.6c-8.3,0-14.4,5.2-14.4,15.1c0,9,5.3,14.8,14.3,14.8c3,0,6.2-0.6,8.2-1.5L612.3,65z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M631.3,25.8c2.4-0.5,6.7-0.9,10.9-0.9c5.5,0,8.9,0.7,11.7,2.6c2.6,1.5,4.3,4.2,4.3,7.7c0,3.8-2.4,7.2-6.8,8.9 v0.1c4.3,1.1,8.3,4.5,8.3,10.2c0,3.7-1.6,6.5-4,8.5c-2.9,2.6-7.7,3.8-15.2,3.8c-4.1,0-7.3-0.3-9.2-0.5V25.8z M638.7,42h3.8 c5.2,0,8.1-2.4,8.1-5.9c0-3.8-2.9-5.6-7.7-5.6c-2.2,0-3.5,0.1-4.3,0.3V42z M638.7,60.8c1,0.1,2.3,0.2,4,0.2c4.8,0,9.1-1.8,9.1-6.9 c0-4.7-4.1-6.7-9.3-6.7h-3.7V60.8z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M674.3,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8l-3.7-11.6H674.3z M686.1,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L675.5,49H686.1z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M704.8,66.2v-41h8.5l10.6,17.6c2.7,4.6,5.1,9.3,7,13.7h0.1c-0.5-5.5-0.7-10.8-0.7-17V25.2h6.9v41h-7.7 l-10.7-18c-2.6-4.5-5.4-9.6-7.4-14.2l-0.2,0.1c0.3,5.3,0.4,10.7,0.4,17.5v14.7H704.8z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/mib_receipt_footer_text"
|
||||||
|
android:pathData="M745.9,25.2h7.4v18.9h0.2c1-1.6,2-3,3-4.4l10.7-14.4h9.2l-14.1,17.5l15,23.5h-8.8L757,47.5l-3.7,4.4v14.4 h-7.4V25.2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- MIB receipt header: solid green under the official app's translucent palm texture -->
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@color/mib_receipt_amount" />
|
||||||
|
<item>
|
||||||
|
<bitmap
|
||||||
|
android:src="@drawable/mib_receipt_texture"
|
||||||
|
android:gravity="fill" />
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 196 KiB |
@@ -48,6 +48,7 @@
|
|||||||
android:id="@+id/languageToggle"
|
android:id="@+id/languageToggle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:baselineAligned="false"
|
||||||
app:singleSelection="true"
|
app:singleSelection="true"
|
||||||
app:selectionRequired="true">
|
app:selectionRequired="true">
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingTop="12dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilName"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:hint="@string/card_verify_name_hint">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPersonName" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilCardNumber"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/card_verify_number_hint">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etCardNumber"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:digits="0123456789 "
|
||||||
|
android:inputType="number"
|
||||||
|
android:maxLength="23"
|
||||||
|
android:autofillHints="creditCardNumber" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilExpiry"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:hint="@string/card_verify_expiry_hint">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etExpiry"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:digits="0123456789/"
|
||||||
|
android:inputType="number"
|
||||||
|
android:maxLength="5"
|
||||||
|
android:autofillHints="creditCardExpirationDate" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilCvv"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:hint="@string/card_verify_cvv_hint"
|
||||||
|
app:endIconMode="password_toggle">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etCvv"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="numberPassword"
|
||||||
|
android:maxLength="4"
|
||||||
|
android:autofillHints="creditCardSecurityCode" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingTop="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:text="Scan this with your authenticator app, or copy the seed. Please keep it private, since anyone who has it can generate your OTP codes."
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<!-- Always black-on-white so scanners read it in dark mode too -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardBackgroundColor="@android:color/white"
|
||||||
|
app:cardCornerRadius="16dp"
|
||||||
|
app:strokeWidth="0dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivSeedQr"
|
||||||
|
android:layout_width="220dp"
|
||||||
|
android:layout_height="220dp"
|
||||||
|
android:layout_margin="12dp"
|
||||||
|
android:contentDescription="OTP seed QR code" />
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSeed"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="true" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnCopySeed"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Copy seed"
|
||||||
|
app:icon="@drawable/ic_copy" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingTop="8dp">
|
||||||
|
|
||||||
|
<!-- Warning: the old seed is overwritten -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
style="@style/Widget.Material3.CardView.Filled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||||
|
app:cardCornerRadius="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_info"
|
||||||
|
app:tint="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSeedWarning"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilNewSeed"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="New OTP seed"
|
||||||
|
app:helperText="Base32 secret or otpauth:// link">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etNewSeed"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:imeOptions="actionDone"
|
||||||
|
android:inputType="textNoSuggestions|textVisiblePassword"
|
||||||
|
android:singleLine="true" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnScanNewSeed"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:contentDescription="@string/scan_otp_qr"
|
||||||
|
android:tooltipText="@string/scan_otp_qr"
|
||||||
|
app:icon="@drawable/ic_qr_scan" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Live preview of the new seed's code, same card as the sign-in screen -->
|
||||||
|
<include
|
||||||
|
android:id="@+id/cardOtp"
|
||||||
|
layout="@layout/view_otp_preview"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Full-screen BML receipt, modelled on docs/bmlapi/tmp/recipt_full_*.jpg.
|
||||||
|
The receipt card is inserted at the top of cardHolder, directly followed by
|
||||||
|
the Save/Share buttons; the rest of the screen shows the footer colour. -->
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@color/bml_receipt_footer">
|
||||||
|
|
||||||
|
<!-- Top bar — status bar inset is added as top padding in code -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/topBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/bml_receipt_bg">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnBack"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_gravity="start|center_vertical"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_chevron_back"
|
||||||
|
app:tint="@color/bml_receipt_amount"
|
||||||
|
android:contentDescription="Back" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:text="Transfer successful"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="@color/bml_receipt_amount"
|
||||||
|
android:fontFamily="@font/sofia_pro" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/scroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardHolder"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- receipt card goes here (index 0) -->
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/bml_receipt_bottom_divider" />
|
||||||
|
|
||||||
|
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/bottomBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@color/bml_receipt_footer"
|
||||||
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSaveFull"
|
||||||
|
style="@style/Widget.Material3.Button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Save receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:fontFamily="@font/sofia_pro"
|
||||||
|
app:backgroundTint="@color/bml_red"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnShareFull"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Share receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="@color/bml_receipt_message"
|
||||||
|
android:fontFamily="@font/sofia_pro"
|
||||||
|
app:rippleColor="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Full-screen MIB receipt. The receipt card is inserted into cardHolder and its green
|
||||||
|
header is extended under the status bar in code; the close button floats over the
|
||||||
|
header just below the status bar. Share/Save sit pinned at the bottom of the screen. -->
|
||||||
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/mib_receipt_bg">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/scroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/cardHolder"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="@color/mib_receipt_divider" />
|
||||||
|
|
||||||
|
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/bottomBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnShareFull"
|
||||||
|
style="@style/Widget.Material3.Button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Share Receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
app:backgroundTint="@color/mib_blue"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSaveFull"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:text="Save Receipt"
|
||||||
|
android:textAllCaps="false"
|
||||||
|
android:letterSpacing="0"
|
||||||
|
android:textSize="17sp"
|
||||||
|
android:textColor="@color/mib_blue"
|
||||||
|
app:backgroundTint="@color/mib_receipt_bg"
|
||||||
|
app:strokeColor="@color/mib_blue"
|
||||||
|
app:strokeWidth="1dp"
|
||||||
|
app:cornerRadius="10dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Close (back) button — status bar inset is added to top margin in code -->
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:src="@drawable/ic_mib_receipt_close"
|
||||||
|
android:contentDescription="Close" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -87,10 +87,19 @@
|
|||||||
|
|
||||||
<!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom -->
|
<!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom -->
|
||||||
<View
|
<View
|
||||||
|
android:id="@+id/bottomSpacer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<!-- Card verification animation (verify mode only); takes the spacer's place -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/flVerifyArea"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<View
|
<View
|
||||||
android:id="@+id/divider"
|
android:id="@+id/divider"
|
||||||
@@ -265,6 +274,73 @@
|
|||||||
app:iconGravity="top"
|
app:iconGravity="top"
|
||||||
app:iconPadding="6dp" />
|
app:iconPadding="6dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnVerify"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="4dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:paddingTop="14dp"
|
||||||
|
android:paddingBottom="14dp"
|
||||||
|
android:text="@string/card_action_verify"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:icon="@drawable/ic_card_verify"
|
||||||
|
app:iconSize="22dp"
|
||||||
|
app:iconGravity="top"
|
||||||
|
app:iconPadding="6dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Card verification actions (verify mode only); styled like llManageButtons -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/llVerifyButtons"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="8dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="12dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnCancelVerify"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="4dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:paddingTop="14dp"
|
||||||
|
android:paddingBottom="14dp"
|
||||||
|
android:text="@string/card_verify_cancel"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:icon="@drawable/ic_close"
|
||||||
|
app:iconSize="22dp"
|
||||||
|
app:iconGravity="top"
|
||||||
|
app:iconPadding="6dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnManualVerify"
|
||||||
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="4dp"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:paddingTop="14dp"
|
||||||
|
android:paddingBottom="14dp"
|
||||||
|
android:text="@string/card_verify_manual"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:icon="@drawable/ic_keyboard"
|
||||||
|
app:iconSize="22dp"
|
||||||
|
app:iconGravity="top"
|
||||||
|
app:iconPadding="6dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -130,63 +130,13 @@
|
|||||||
android:maxLength="6" />
|
android:maxLength="6" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
<include
|
||||||
android:id="@+id/cardOtp"
|
android:id="@+id/cardOtp"
|
||||||
|
layout="@layout/view_otp_preview"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp" />
|
||||||
android:visibility="invisible"
|
|
||||||
android:clickable="true"
|
|
||||||
android:focusable="true"
|
|
||||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="0dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:paddingHorizontal="16dp"
|
|
||||||
android:paddingVertical="12dp"
|
|
||||||
android:gravity="center_vertical">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Current OTP"
|
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
|
||||||
android:textColor="?attr/colorOnSecondaryContainer" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvOtpCode"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
|
||||||
android:textColor="?attr/colorOnSecondaryContainer"
|
|
||||||
android:letterSpacing="0.15"
|
|
||||||
android:fontFamily="monospace" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.CircularProgressIndicator
|
|
||||||
android:id="@+id/otpTimer"
|
|
||||||
android:layout_width="32dp"
|
|
||||||
android:layout_height="32dp"
|
|
||||||
app:indicatorSize="32dp"
|
|
||||||
app:trackThickness="3dp"
|
|
||||||
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
|
||||||
app:trackColor="?attr/colorSecondaryContainer"
|
|
||||||
android:indeterminate="false" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvError"
|
android:id="@+id/tvError"
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<FrameLayout
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent">
|
||||||
android:orientation="vertical">
|
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/recyclerView"
|
android:id="@+id/recyclerView"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:paddingTop="16dp"
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="16dp"
|
||||||
android:clipToPadding="false" />
|
android:clipToPadding="false" />
|
||||||
|
|
||||||
</LinearLayout>
|
<LinearLayout
|
||||||
|
android:id="@+id/emptyState"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="32dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:alpha="0.6"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_nav_otp"
|
||||||
|
android:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="No OTP codes yet.\nAdd an MIB or BML login to see its codes here."
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|||||||
@@ -95,6 +95,7 @@
|
|||||||
|
|
||||||
<!-- Include phone number toggle -->
|
<!-- Include phone number toggle -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutIncludePhone"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="#FFFFFF">
|
android:background="@color/bml_receipt_bg">
|
||||||
|
|
||||||
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
|
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
|
||||||
<ImageView
|
<ImageView
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
android:layout_gravity="center_horizontal"
|
android:layout_gravity="center_horizontal"
|
||||||
android:layout_marginTop="20dp"
|
android:layout_marginTop="20dp"
|
||||||
android:src="@drawable/bml_icon"
|
android:src="@drawable/bml_logo_vector"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:adjustViewBounds="true"
|
android:adjustViewBounds="true"
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="@null" />
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
android:layout_marginBottom="20dp"
|
android:layout_marginBottom="20dp"
|
||||||
android:id="@+id/tvMessage"
|
android:id="@+id/tvMessage"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:textColor="#2D2D2D"
|
android:textColor="@color/bml_receipt_message"
|
||||||
android:fontFamily="@font/nunito_sans"
|
android:fontFamily="@font/nunito_sans"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_marginBottom="13dp"
|
android:layout_marginBottom="13dp"
|
||||||
android:textSize="42sp"
|
android:textSize="42sp"
|
||||||
android:textColor="#242424"
|
android:textColor="@color/bml_receipt_amount"
|
||||||
android:fontFamily="@font/sofia_pro"
|
android:fontFamily="@font/sofia_pro"
|
||||||
android:gravity="center" />
|
android:gravity="center" />
|
||||||
|
|
||||||
@@ -109,66 +109,66 @@
|
|||||||
|
|
||||||
<!-- Status (value = green #8BC155) -->
|
<!-- Status (value = green #8BC155) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Message (value wraps if long) -->
|
<!-- Message (value wraps if long) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Reference -->
|
<!-- Reference -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Transaction date -->
|
<!-- Transaction date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- From (value uppercase) -->
|
<!-- From (value uppercase) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
|
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- To (stacked name + account) -->
|
<!-- To (stacked name + account) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
|
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
|
||||||
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
|
||||||
|
|
||||||
<!-- Amount (value = green #8BC155) -->
|
<!-- Amount (value = green #8BC155) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Remarks (hidden when empty) -->
|
<!-- Remarks (hidden when empty) -->
|
||||||
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" android:visibility="gone" />
|
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" android:visibility="gone" />
|
||||||
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
|
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
||||||
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:background="#F5F5F5"
|
android:background="@color/bml_receipt_footer"
|
||||||
android:paddingVertical="22dp">
|
android:paddingVertical="22dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
android:overScrollMode="never"
|
android:overScrollMode="never"
|
||||||
android:scrollbars="none">
|
android:scrollbars="none">
|
||||||
|
|
||||||
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
<!-- Renderable receipt card -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/receiptCard"
|
android:id="@+id/receiptCard"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
@@ -23,76 +23,35 @@
|
|||||||
android:layout_marginHorizontal="40dp"
|
android:layout_marginHorizontal="40dp"
|
||||||
android:orientation="vertical">
|
android:orientation="vertical">
|
||||||
|
|
||||||
<!-- Green header — fills all space not taken by body -->
|
<!-- Green header — from/to avatars and names -->
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
android:id="@+id/receiptHeader"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="200dp"
|
android:layout_height="160dp"
|
||||||
android:background="@drawable/trx_success_bg">
|
android:background="@drawable/mib_receipt_header_bg">
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_gravity="center"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="TRANSACTION RECEIPT"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:letterSpacing="0.08"
|
|
||||||
android:layout_marginBottom="22dp" />
|
|
||||||
|
|
||||||
<ImageView
|
|
||||||
android:layout_width="64dp"
|
|
||||||
android:layout_height="64dp"
|
|
||||||
android:src="@drawable/ic_receipt_check"
|
|
||||||
android:contentDescription="@null" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="SUCCESSFUL"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:letterSpacing="0.05"
|
|
||||||
android:layout_marginTop="18dp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<!-- White body — fixed size content -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:background="#FFFFFF"
|
|
||||||
android:paddingTop="16dp">
|
|
||||||
|
|
||||||
<!-- Avatars row -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingHorizontal="24dp"
|
android:paddingHorizontal="24dp">
|
||||||
android:paddingBottom="14dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<com.google.android.material.imageview.ShapeableImageView
|
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
||||||
|
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
||||||
|
<ImageView
|
||||||
android:id="@+id/ivFromAvatar"
|
android:id="@+id/ivFromAvatar"
|
||||||
android:layout_width="52dp"
|
android:layout_width="52dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvFromLabel"
|
android:id="@+id/tvFromLabel"
|
||||||
@@ -100,7 +59,9 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:textColor="#565656"
|
android:maxWidth="110dp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:maxLines="2" />
|
android:maxLines="2" />
|
||||||
|
|
||||||
@@ -109,22 +70,25 @@
|
|||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
android:layout_height="24dp"
|
android:layout_height="24dp"
|
||||||
android:layout_marginHorizontal="12dp"
|
android:layout_marginHorizontal="10dp"
|
||||||
android:src="@drawable/ic_arrow_right"
|
android:src="@drawable/ic_arrow_right"
|
||||||
|
android:tint="#FFFFFF"
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center">
|
android:gravity="center">
|
||||||
|
|
||||||
<com.google.android.material.imageview.ShapeableImageView
|
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
|
||||||
|
hardware-layer mask doesn't render on the scaled card or in captures) -->
|
||||||
|
<ImageView
|
||||||
android:id="@+id/ivToAvatar"
|
android:id="@+id/ivToAvatar"
|
||||||
android:layout_width="52dp"
|
android:layout_width="52dp"
|
||||||
android:layout_height="52dp"
|
android:layout_height="52dp"
|
||||||
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
android:scaleType="fitCenter"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvToLabel"
|
android:id="@+id/tvToLabel"
|
||||||
@@ -132,7 +96,9 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="6dp"
|
||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:textColor="#565656"
|
android:maxWidth="110dp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:maxLines="2" />
|
android:maxLines="2" />
|
||||||
|
|
||||||
@@ -140,81 +106,92 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
</FrameLayout>
|
||||||
|
|
||||||
<!-- Total Amount -->
|
<!-- Body -->
|
||||||
<TextView
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="TOTAL AMOUNT"
|
android:orientation="vertical"
|
||||||
android:textSize="13sp"
|
android:background="@color/mib_receipt_bg">
|
||||||
android:textColor="#a0a2a1"
|
|
||||||
android:letterSpacing="0.08"
|
|
||||||
android:gravity="center"
|
|
||||||
android:paddingTop="10dp"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvAmount"
|
android:id="@+id/tvAmount"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="34sp"
|
android:textSize="24sp"
|
||||||
android:textColor="#f14f0f"
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/mib_receipt_amount"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingBottom="10dp" />
|
android:paddingTop="14dp" />
|
||||||
|
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<TextView
|
||||||
|
android:id="@+id/tvStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Success"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="@color/mib_receipt_amount"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingBottom="6dp" />
|
||||||
|
|
||||||
<!-- Reference # -->
|
<!-- Transaction # -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction#" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvReferenceNo" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- To Account -->
|
<!-- From (sender profile name) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="From" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvFromName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- To Bank -->
|
<!-- To Account (recipient name + account number) -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="To Account" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvToAccount" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
|
<!-- Bank -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Bank" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
|
<TextView android:id="@+id/tvToBank" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
|
</LinearLayout>
|
||||||
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
|
<!-- Transaction Type -->
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Type" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
|
<TextView android:id="@+id/tvTransactionType" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
|
</LinearLayout>
|
||||||
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Transaction Date -->
|
<!-- Transaction Date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvTransactionDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Value Date -->
|
<!-- Processed Date -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Processed Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvValueDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
|
||||||
|
|
||||||
<!-- Purpose -->
|
<!-- Remarks -->
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="15sp" android:textColor="#a0a2a1" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Remarks" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
|
||||||
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
|
<TextView android:id="@+id/tvPurpose" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
|
||||||
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- MIB footer -->
|
<!-- MIB footer -->
|
||||||
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
@@ -224,19 +201,10 @@
|
|||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="28dp"
|
android:layout_height="22dp"
|
||||||
android:src="@drawable/mib_logo"
|
android:src="@drawable/mib_logo_full"
|
||||||
android:adjustViewBounds="true"
|
android:adjustViewBounds="true"
|
||||||
android:contentDescription="@null" />
|
android:contentDescription="Maldives Islamic Bank" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:text="MALDIVES ISLAMIC BANK"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textColor="#000000"
|
|
||||||
android:letterSpacing="0.05" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
android:layout_width="32dp"
|
android:layout_width="32dp"
|
||||||
android:layout_height="32dp"
|
android:layout_height="32dp"
|
||||||
android:layout_marginEnd="16dp"
|
android:layout_marginEnd="16dp"
|
||||||
android:src="@drawable/bml_icon"
|
android:src="@drawable/bml_logo_vector"
|
||||||
android:scaleType="fitCenter"
|
android:scaleType="fitCenter"
|
||||||
android:contentDescription="BML" />
|
android:contentDescription="BML" />
|
||||||
|
|
||||||
|
|||||||
@@ -282,6 +282,7 @@
|
|||||||
android:id="@+id/languageToggle"
|
android:id="@+id/languageToggle"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:baselineAligned="false"
|
||||||
app:singleSelection="true"
|
app:singleSelection="true"
|
||||||
app:selectionRequired="true">
|
app:selectionRequired="true">
|
||||||
|
|
||||||
@@ -303,6 +304,34 @@
|
|||||||
|
|
||||||
</com.google.android.material.button.MaterialButtonToggleGroup>
|
</com.google.android.material.button.MaterialButtonToggleGroup>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/settings_receipts"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/settings_always_fullscreen_receipt"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchFullscreenReceipt"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp"
|
||||||
|
android:clipToPadding="false">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvLoginsTitle"
|
android:id="@+id/tvLoginsTitle"
|
||||||
@@ -20,10 +21,12 @@
|
|||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Rows pad back the negative margin so the tap/drag highlight has a gap around the content -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/loginsContainer"
|
android:id="@+id/loginsContainer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="-12dp"
|
||||||
android:orientation="vertical" />
|
android:orientation="vertical" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
|
|||||||
@@ -148,12 +148,16 @@
|
|||||||
app:endIconDrawable="@android:drawable/ic_menu_search"
|
app:endIconDrawable="@android:drawable/ic_menu_search"
|
||||||
app:endIconContentDescription="@string/transfer_lookup_account">
|
app:endIconContentDescription="@string/transfer_lookup_account">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||||
android:id="@+id/etTo"
|
android:id="@+id/etTo"
|
||||||
|
style="@style/Widget.Material3.AutoCompleteTextView.OutlinedBox"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textNoSuggestions"
|
android:inputType="textNoSuggestions"
|
||||||
android:maxLines="1" />
|
android:maxLines="1"
|
||||||
|
android:completionThreshold="3"
|
||||||
|
android:dropDownHeight="wrap_content"
|
||||||
|
android:imeOptions="actionSearch" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
android:paddingHorizontal="16dp"
|
android:paddingHorizontal="16dp"
|
||||||
android:paddingVertical="10dp">
|
android:paddingVertical="10dp">
|
||||||
|
|
||||||
@@ -26,25 +27,6 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="parent">
|
app:layout_constraintBottom_toBottomOf="parent">
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnEditContact"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_edit"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:contentDescription="@string/contact_edit" />
|
|
||||||
|
|
||||||
<ImageButton
|
|
||||||
android:id="@+id/btnDeleteContact"
|
|
||||||
android:layout_width="36dp"
|
|
||||||
android:layout_height="36dp"
|
|
||||||
android:background="?attr/selectableItemBackgroundBorderless"
|
|
||||||
android:src="@drawable/ic_delete"
|
|
||||||
android:padding="6dp"
|
|
||||||
android:tint="?attr/colorError"
|
|
||||||
android:contentDescription="@string/contact_delete" />
|
|
||||||
|
|
||||||
<ImageButton
|
<ImageButton
|
||||||
android:id="@+id/btnTransferContact"
|
android:id="@+id/btnTransferContact"
|
||||||
android:layout_width="36dp"
|
android:layout_width="36dp"
|
||||||
|
|||||||
@@ -2,55 +2,163 @@
|
|||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
style="@style/Widget.Material3.CardView.Filled"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
app:cardCornerRadius="16dp"
|
app:cardCornerRadius="24dp">
|
||||||
app:cardElevation="2dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="20dp">
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="16dp"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
<TextView
|
<!-- Header: logo, bank + holder name, countdown ring -->
|
||||||
android:id="@+id/tvOtpLabel"
|
<LinearLayout
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
|
||||||
android:textColor="?attr/colorOnSurfaceVariant"
|
|
||||||
android:layout_marginBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvOtpCode"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
|
||||||
android:textColor="?attr/colorPrimary"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:letterSpacing="0.15"
|
|
||||||
android:layout_marginBottom="16dp" />
|
|
||||||
|
|
||||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
|
||||||
android:id="@+id/otpProgress"
|
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:max="30"
|
android:orientation="horizontal"
|
||||||
app:trackCornerRadius="4dp"
|
android:gravity="center_vertical">
|
||||||
app:indicatorColor="?attr/colorPrimary"
|
|
||||||
app:trackColor="?attr/colorSurfaceVariant" />
|
|
||||||
|
|
||||||
<TextView
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
android:id="@+id/tvOtpCountdown"
|
android:id="@+id/ivBankLogo"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpBank"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleSmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/otpProgress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:indeterminate="false"
|
||||||
|
android:max="30"
|
||||||
|
app:indicatorSize="40dp"
|
||||||
|
app:trackThickness="3dp"
|
||||||
|
app:trackCornerRadius="2dp"
|
||||||
|
app:indicatorColor="?attr/colorPrimary"
|
||||||
|
app:trackColor="?attr/colorOutlineVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCountdown"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Current code + copy -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="12dp"
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
android:orientation="horizontal"
|
||||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCode"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:letterSpacing="0.12"
|
||||||
|
android:textAppearance="?attr/textAppearanceDisplaySmall"
|
||||||
|
android:textColor="?attr/colorPrimary"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCopyOtp"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="Copy OTP"
|
||||||
|
android:tooltipText="Copy OTP"
|
||||||
|
app:icon="@drawable/ic_copy" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:layout_marginBottom="4dp" />
|
||||||
|
|
||||||
|
<!-- Next code + copy -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="8dp"
|
||||||
|
android:text="Next"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNextOtpCode"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCopyNextOtp"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:contentDescription="Copy next OTP"
|
||||||
|
android:tooltipText="Copy next OTP"
|
||||||
|
app:icon="@drawable/ic_copy"
|
||||||
|
app:iconTint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingBottom="24dp">
|
||||||
|
|
||||||
|
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<com.google.android.material.imageview.ShapeableImageView
|
||||||
|
android:id="@+id/ivPhoto"
|
||||||
|
android:layout_width="112dp"
|
||||||
|
android:layout_height="112dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:scaleType="centerCrop"
|
||||||
|
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionTransfer"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnTransfer"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/transfer"
|
||||||
|
app:icon="@drawable/ic_send"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/transfer"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionQr"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnQr"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_qr"
|
||||||
|
app:icon="@drawable/ic_qr_code"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvQrLabel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_qr"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionEdit"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnEdit"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_edit"
|
||||||
|
app:icon="@drawable/ic_edit"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_edit"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/actionDelete"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnDelete"
|
||||||
|
style="@style/Widget.Material3.Button.IconButton.Filled.Tonal"
|
||||||
|
android:layout_width="56dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:contentDescription="@string/contact_delete"
|
||||||
|
app:backgroundTint="?attr/colorErrorContainer"
|
||||||
|
app:icon="@drawable/ic_delete"
|
||||||
|
android:insetLeft="0dp"
|
||||||
|
android:insetTop="0dp"
|
||||||
|
android:insetRight="0dp"
|
||||||
|
android:insetBottom="0dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
app:iconGravity="textStart"
|
||||||
|
app:iconPadding="0dp"
|
||||||
|
app:iconSize="24dp"
|
||||||
|
app:iconTint="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/contact_delete"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||||
|
android:textColor="?attr/colorError" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_manage_card"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAccount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_account_number"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnShareAccount"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/contact_share_account"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:src="@drawable/ic_share"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnCopyAccount"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/contact_copy_account"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:src="@drawable/ic_copy"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowRealName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_contacts"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRealName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_account_name"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowCurrency"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_currency"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCurrency"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_currency"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="64dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivBankIcon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:src="@drawable/ic_nav_finances"
|
||||||
|
app:tint="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyLarge"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:textIsSelectable="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/contact_bank"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sourceSection"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.divider.MaterialDivider
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginVertical="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:minHeight="56dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingVertical="8dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/ivSourceIcon"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:importantForAccessibility="no" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="24dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSource"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvSourceBank"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodySmall"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Live TOTP preview card, shared by the sign-in screen and the OTP screen's "Update seed" dialog -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="invisible"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="0dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Current OTP"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvOtpCode"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer"
|
||||||
|
android:letterSpacing="0.15"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="end"
|
||||||
|
android:alpha="0.7">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Next"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNextOtpCode"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnSecondaryContainer"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.CircularProgressIndicator
|
||||||
|
android:id="@+id/otpTimer"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
app:indicatorSize="32dp"
|
||||||
|
app:trackThickness="3dp"
|
||||||
|
app:indicatorColor="?attr/colorOnSecondaryContainer"
|
||||||
|
app:trackColor="?attr/colorSecondaryContainer"
|
||||||
|
android:indeterminate="false" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -145,4 +145,5 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
<string name="connectivity_no_internet">އިންޓަނެޓް ބައްލަވާ، ދެން ތިޖޫރީ ލޯޑް ކުރޭ</string>
|
||||||
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
<string name="connectivity_server_error">%s އާ ގުޅުމުގައި މައްސަލައެއް</string>
|
||||||
|
<string name="drag_to_reorder">ދަމާ ތަރުތީބު ބަދަލުކުރޭ</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- BML receipt — dark mode -->
|
||||||
|
<color name="bml_receipt_bg">#191A1C</color>
|
||||||
|
<color name="bml_receipt_message">#DEDEE0</color>
|
||||||
|
<color name="bml_receipt_amount">#DEDEE0</color>
|
||||||
|
<color name="bml_receipt_label">#DEDEE0</color>
|
||||||
|
<color name="bml_receipt_footer">#000000</color>
|
||||||
|
<color name="bml_receipt_divider">#3D3E40</color>
|
||||||
|
<color name="bml_receipt_bottom_divider">#1E1F21</color>
|
||||||
|
|
||||||
|
<!-- MIB receipt — dark mode -->
|
||||||
|
<color name="mib_receipt_bg">#1A1A3C</color>
|
||||||
|
<color name="mib_receipt_label">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_value">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_divider">#33335C</color>
|
||||||
|
<color name="mib_receipt_footer_text">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_close_ring">#000000</color>
|
||||||
|
</resources>
|
||||||
@@ -5,4 +5,24 @@
|
|||||||
<color name="seed_secondary">#9AD141</color>
|
<color name="seed_secondary">#9AD141</color>
|
||||||
<color name="color_unpaid">#E85D04</color>
|
<color name="color_unpaid">#E85D04</color>
|
||||||
<color name="ic_logo_background">#E8B547</color>
|
<color name="ic_logo_background">#E8B547</color>
|
||||||
|
|
||||||
|
<!-- BML receipt (dark variants in values-night/colors.xml) -->
|
||||||
|
<color name="bml_receipt_bg">#FFFFFF</color>
|
||||||
|
<color name="bml_receipt_message">#2D2D2D</color>
|
||||||
|
<color name="bml_receipt_amount">#242424</color>
|
||||||
|
<color name="bml_receipt_label">#000000</color>
|
||||||
|
<color name="bml_receipt_footer">#F5F5F5</color>
|
||||||
|
<color name="bml_receipt_divider">#E9E9E9</color>
|
||||||
|
<color name="bml_receipt_bottom_divider">#EBEBEB</color>
|
||||||
|
<color name="bml_red">#E21B23</color>
|
||||||
|
|
||||||
|
<!-- MIB receipt (dark variants in values-night/colors.xml) -->
|
||||||
|
<color name="mib_receipt_bg">#FFFFFF</color>
|
||||||
|
<color name="mib_receipt_label">#000000</color>
|
||||||
|
<color name="mib_receipt_value">#000000</color>
|
||||||
|
<color name="mib_receipt_divider">#CAC4D0</color>
|
||||||
|
<color name="mib_receipt_footer_text">#000000</color>
|
||||||
|
<color name="mib_receipt_amount">#1EA833</color>
|
||||||
|
<color name="mib_receipt_close_ring">#FFFFFF</color>
|
||||||
|
<color name="mib_blue">#006FFC</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -127,7 +127,6 @@
|
|||||||
<string name="paymvqr_save_failed">Failed to save image</string>
|
<string name="paymvqr_save_failed">Failed to save image</string>
|
||||||
<string name="paymvqr_include_phone">Include phone number</string>
|
<string name="paymvqr_include_phone">Include phone number</string>
|
||||||
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
||||||
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
|
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<string name="action_lock">Lock app</string>
|
<string name="action_lock">Lock app</string>
|
||||||
@@ -158,6 +157,8 @@
|
|||||||
<string name="theme_dark">Dark</string>
|
<string name="theme_dark">Dark</string>
|
||||||
<string name="settings_pitch_black">Pitch Black</string>
|
<string name="settings_pitch_black">Pitch Black</string>
|
||||||
<string name="settings_accent_color">Accent Color</string>
|
<string name="settings_accent_color">Accent Color</string>
|
||||||
|
<string name="settings_receipts">Receipts</string>
|
||||||
|
<string name="settings_always_fullscreen_receipt">Always show full screen receipt</string>
|
||||||
<string name="accent_blue">Blue</string>
|
<string name="accent_blue">Blue</string>
|
||||||
<string name="accent_orange">Red</string>
|
<string name="accent_orange">Red</string>
|
||||||
<string name="accent_green">Green</string>
|
<string name="accent_green">Green</string>
|
||||||
@@ -295,6 +296,9 @@
|
|||||||
<!-- BML QR Pay -->
|
<!-- BML QR Pay -->
|
||||||
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
<string name="bml_qr_looking_up">Looking up merchant…</string>
|
||||||
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
|
||||||
|
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
|
||||||
|
<string name="bml_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
|
||||||
|
<string name="bml_card_pay_already_paid">This payment has already been completed.</string>
|
||||||
<string name="bml_qr_payment_success">Payment Successful</string>
|
<string name="bml_qr_payment_success">Payment Successful</string>
|
||||||
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
<string name="bml_qr_select_account">Select a BML account to pay from</string>
|
||||||
|
|
||||||
@@ -332,6 +336,14 @@
|
|||||||
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
<string name="contact_delete_message">Remove %s from your contacts?</string>
|
||||||
<string name="contact_deleted">Contact deleted</string>
|
<string name="contact_deleted">Contact deleted</string>
|
||||||
<string name="contact_delete_failed">Could not delete contact</string>
|
<string name="contact_delete_failed">Could not delete contact</string>
|
||||||
|
<string name="contact_account_number">Account number</string>
|
||||||
|
<string name="contact_account_name">Account name</string>
|
||||||
|
<string name="contact_bank">Bank</string>
|
||||||
|
<string name="contact_qr">QR</string>
|
||||||
|
<string name="contact_delete_warning">This can\'t be undone.</string>
|
||||||
|
<string name="contact_copy_account">Copy account number</string>
|
||||||
|
<string name="contact_share_account">Share account details</string>
|
||||||
|
<string name="contact_account_copied">Account number copied</string>
|
||||||
|
|
||||||
<!-- Financing -->
|
<!-- Financing -->
|
||||||
<string name="financing_empty">No financing deals found</string>
|
<string name="financing_empty">No financing deals found</string>
|
||||||
@@ -377,6 +389,30 @@
|
|||||||
<string name="card_action_freeze">Freeze</string>
|
<string name="card_action_freeze">Freeze</string>
|
||||||
<string name="card_action_unfreeze">Unfreeze</string>
|
<string name="card_action_unfreeze">Unfreeze</string>
|
||||||
<string name="card_action_block">Block</string>
|
<string name="card_action_block">Block</string>
|
||||||
|
<string name="card_action_verify">Verify</string>
|
||||||
|
<string name="card_action_verified">Verified</string>
|
||||||
|
<string name="card_verify_already">Card already verified. Press and hold to update.</string>
|
||||||
|
<string name="card_verify_title">Verify Card</string>
|
||||||
|
<string name="card_verify_tap">Tap card to verify</string>
|
||||||
|
<string name="card_verify_reading">Reading card… hold still</string>
|
||||||
|
<string name="card_verify_matched">Card matched</string>
|
||||||
|
<string name="card_verify_read_failed">Couldn\'t read the card, try again</string>
|
||||||
|
<string name="card_verify_mismatch">Card ending %1$s doesn\'t match</string>
|
||||||
|
<string name="card_verify_cancel">Cancel Verification</string>
|
||||||
|
<string name="card_verify_manual">Manually Verify</string>
|
||||||
|
<string name="card_verify_manual_title">Enter Your Card Details</string>
|
||||||
|
<string name="card_verify_nfc_disabled_message">Turn on NFC to verify your card by tapping it, or enter the details manually.</string>
|
||||||
|
<string name="card_verify_cvv_title">Card ending %1$s</string>
|
||||||
|
<string name="card_verify_cvv_hint">CVV</string>
|
||||||
|
<string name="card_verify_cvv_invalid">Enter a 3 or 4 digit CVV</string>
|
||||||
|
<string name="card_verify_confirm">Verify</string>
|
||||||
|
<string name="card_verify_name_hint">Name on card</string>
|
||||||
|
<string name="card_verify_number_hint">Card number</string>
|
||||||
|
<string name="card_verify_expiry_hint">Expiry (MM/YY)</string>
|
||||||
|
<string name="card_verify_number_invalid">Enter a valid card number</string>
|
||||||
|
<string name="card_verify_number_mismatch">Number must end in %1$s</string>
|
||||||
|
<string name="card_verify_expiry_invalid">Enter a valid expiry, e.g. 08/29</string>
|
||||||
|
<string name="card_verify_success">Card verified</string>
|
||||||
<string name="card_freeze_confirm_title">Freeze card?</string>
|
<string name="card_freeze_confirm_title">Freeze card?</string>
|
||||||
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
|
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
|
||||||
<string name="card_unfreeze_confirm_title">Unfreeze card?</string>
|
<string name="card_unfreeze_confirm_title">Unfreeze card?</string>
|
||||||
@@ -391,4 +427,5 @@
|
|||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
<string name="connectivity_no_internet">Please check your connection and reload Thijooree</string>
|
||||||
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
<string name="connectivity_server_error">Connectivity issue with %s</string>
|
||||||
|
<string name="drag_to_reorder">Drag to reorder</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
|
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
|
||||||
|
|
||||||
|
<!-- Destructive confirmation dialog: centered icon/title, filled red confirm button -->
|
||||||
|
<style name="ThemeOverlay.BasedBank.DestructiveDialog" parent="ThemeOverlay.Material3.MaterialAlertDialog.Centered">
|
||||||
|
<item name="buttonBarPositiveButtonStyle">@style/Widget.BasedBank.DestructiveDialogButton</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style name="Widget.BasedBank.DestructiveDialogButton" parent="Widget.Material3.Button">
|
||||||
|
<item name="backgroundTint">?attr/colorError</item>
|
||||||
|
<item name="android:textColor">?attr/colorOnError</item>
|
||||||
|
</style>
|
||||||
|
|
||||||
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
|
||||||
<item name="colorPrimary">@color/seed_primary</item>
|
<item name="colorPrimary">@color/seed_primary</item>
|
||||||
<item name="colorSecondary">@color/seed_secondary</item>
|
<item name="colorSecondary">@color/seed_secondary</item>
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
|
|||||||
|
|
||||||
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
||||||
|
|
||||||
|
### 3. POS QR (`mv.com.bml.qtr`)
|
||||||
|
|
||||||
|
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
|
||||||
|
`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as
|
||||||
|
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
|
||||||
|
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
|
||||||
|
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
|
||||||
|
understands it. See
|
||||||
|
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PayMV QR Format (TLV)
|
## PayMV QR Format (TLV)
|
||||||
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
|
|||||||
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
||||||
```
|
```
|
||||||
|
|
||||||
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
|
`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
|
||||||
|
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference.
|
||||||
|
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
|
||||||
|
rather than relying on either being required.
|
||||||
|
|
||||||
### Headers
|
### Headers
|
||||||
|
|
||||||
| Header | Value |
|
| Header | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `accept` | `application/json` — **required in practice**, see below |
|
||||||
| `Authorization` | `Bearer <access_token>` |
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
| `x-app-version` | `2.1.44.348` |
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
|
||||||
|
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
|
||||||
|
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
|
||||||
|
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --request GET \
|
curl --request GET \
|
||||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
||||||
|
--header 'accept: application/json' \
|
||||||
--header 'Authorization: Bearer <access_token>' \
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
--header 'x-app-version: 2.1.44.348'
|
--header 'x-app-version: 2.1.44.348'
|
||||||
@@ -116,6 +136,16 @@ curl --request GET \
|
|||||||
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
||||||
| `currency` | Currency code (typically `"MVR"`) |
|
| `currency` | Currency code (typically `"MVR"`) |
|
||||||
|
|
||||||
|
### Failure Responses
|
||||||
|
|
||||||
|
`success: false` comes back with a code and a user-facing message; the client shows BML's own
|
||||||
|
wording and keeps the user on the Transfer screen.
|
||||||
|
|
||||||
|
| Code | Message |
|
||||||
|
|---|---|
|
||||||
|
| `103` | The payment request has expired |
|
||||||
|
| `112` | Unsupported payment link |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Pay (3-Step TOTP Flow)
|
## Step 2 — Pay (3-Step TOTP Flow)
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
# Merchant Card Payment (no BML Pay)
|
||||||
|
|
||||||
|
BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`,
|
||||||
|
e.g. the bill links Fenaka and Fahipay send) are paid one of two ways depending on what the
|
||||||
|
merchant has enabled:
|
||||||
|
|
||||||
|
| Merchant capability | How it is paid | Doc |
|
||||||
|
|---|---|---|
|
||||||
|
| **BML Pay** (`bml_mpos`) enabled | Fetch the merchant's QR text, pay it via the normal QR flow | [QR Payment](13-qr-payment.md) |
|
||||||
|
| **Card only** (no BML Pay) | Enter card details → Pomelo tokenise → MPGS + 3-D Secure | **this doc** |
|
||||||
|
|
||||||
|
The payment page is a React app (Pomelo Pay, white-labelled as "Bank of Maldives Merchant
|
||||||
|
Services"). The card flow here replays the exact requests that page and the issuer's 3-D Secure
|
||||||
|
challenge make in a browser. Reconstructed from `docs/bmlapi/tmp/bmlpaywithid-verifiedcard.har`.
|
||||||
|
|
||||||
|
> ⚠️ This flow is **scraped browser/ACS traffic**, not a stable API. See
|
||||||
|
> [Fragility](#fragility--what-can-break) before relying on it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hosts
|
||||||
|
|
||||||
|
| Purpose | Base URL | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Payment page (`/paynow`) | `https://transaction.merchants.bankofmaldives.com.mv` | Behind Cloudflare — **browser User-Agent required** |
|
||||||
|
| Merchant API | `https://api.merchants.bankofmaldives.com.mv` | Tolerates non-browser UA |
|
||||||
|
| Card tokenisation (Pomelo CDE) | `https://api.pay.pomelopay.com` | `bin-lookup` |
|
||||||
|
| 3-D Secure ACS (Wibmo) | `https://secure-acs2ui-bk2-<dc>.wibmo.com` | Behind Cloudflare; `<dc>` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) |
|
||||||
|
| Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detecting the merchant type
|
||||||
|
|
||||||
|
`GET /<id>/paynow` returns server-rendered HTML with everything inline in a
|
||||||
|
`window.appData = {…}` script. Parse that JSON (the code reads between `window.appData = ` and the
|
||||||
|
next `</script>`):
|
||||||
|
|
||||||
|
| `window.appData` field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `transaction.state` | `QR_CODE_GENERATED` normally; `CONFIRMED` if already paid |
|
||||||
|
| `transaction.payAmount` / `transaction.amount` | Amount in **cents** (payAmount preferred; falls back to amount) |
|
||||||
|
| `transaction.payCurrency` / `transaction.currency` | e.g. `MVR` |
|
||||||
|
| `merchant.tradingName` / `registeredName` | Display name |
|
||||||
|
| `availableProviders[]` | Contains `{value:"bml_mpos", enabled:true}` **iff BML Pay is enabled** |
|
||||||
|
| `pomeloJsProviders[]` | Contains `"mpgs"` when card entry is offered |
|
||||||
|
| `pomeloJsKey` | `pk_production_…` — the card form's auth token (a JWT carrying the merchant id) |
|
||||||
|
|
||||||
|
**Decision:** `supportsBmlPay = availableProviders` contains an enabled `bml_mpos`;
|
||||||
|
`supportsCard = pomeloJsKey present && pomeloJsProviders` contains `mpgs`.
|
||||||
|
Route to the card flow only when **`!supportsBmlPay && supportsCard`**.
|
||||||
|
|
||||||
|
> The `/paynow` host is fronted by Cloudflare and returns **403** to the `okhttp/*` User-Agent.
|
||||||
|
> Send a browser UA (`BML_WEB_USER_AGENT`) + `Accept: text/html…`. The `api.merchants…` host is
|
||||||
|
> not UA-gated, which is why the PATCHes below work with the default client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow overview
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /<id>/paynow → window.appData (merchant type, pomeloJsKey)
|
||||||
|
PATCH transactions/<id> {activeBrowserId} ─┐ announce browser
|
||||||
|
PATCH transactions/<id> {fx:"reset"} ─┘
|
||||||
|
GET public-client/credentials/<id> → RSA public key + Pomelo apiKey
|
||||||
|
POST api.pay.pomelopay.com/bin-lookup → tokenId (card encrypted here)
|
||||||
|
POST public-client/transactions/next-action RATE_OPTIONS → WAIT
|
||||||
|
POST …next-action POLL (every 5s) → THREEDS + 3dsUrl
|
||||||
|
GET <3dsUrl> (modirum/render-tds) → auto-POST form (creq → ACS)
|
||||||
|
POST <ACS creq url> creq → OTP channel picker
|
||||||
|
POST <ACS creq url> destValue=token… → OTP entry page
|
||||||
|
POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
|
||||||
|
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
|
||||||
|
POST transactions/mpgsNotification/<id> → records the verdict
|
||||||
|
POST …next-action POLL → TRANSACTION_CONFIRMED
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Announce browser
|
||||||
|
|
||||||
|
Two unauthenticated PATCHes the page sends on load (needed by `fx`/state bookkeeping). `Origin` /
|
||||||
|
`Referer` are the transaction host.
|
||||||
|
|
||||||
|
```
|
||||||
|
PATCH https://api.merchants.bankofmaldives.com.mv/transactions/<id>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{"activeBrowserId":"<id>_<epoch-millis>"}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
PATCH …/transactions/<id>
|
||||||
|
{"fx":"reset"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Credentials
|
||||||
|
|
||||||
|
```
|
||||||
|
GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/<id>
|
||||||
|
Authorization: <pomeloJsKey> # the pk_production_… from the page
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"publicKey": {
|
||||||
|
"publicKeyId": "3edf1db0-…",
|
||||||
|
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN…\n-----END PUBLIC KEY-----"
|
||||||
|
},
|
||||||
|
"apiKey": "UU8a9m4Q…",
|
||||||
|
"binLookupUrl": "https://api.pay.pomelopay.com/bin-lookup"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tokenise the card (`bin-lookup`)
|
||||||
|
|
||||||
|
The card number, CVV and expiry are **RSA-OAEP(SHA-1)** encrypted with `publicKeyPem`, Base64
|
||||||
|
(no-wrap) encoded. The Pomelo JS uses WebCrypto `{name:"RSA-OAEP", hash:"SHA-1"}` over the plain
|
||||||
|
strings — the Java equivalent is `RSA/ECB/OAEPPadding` with
|
||||||
|
`OAEPParameterSpec("SHA-1","MGF1",MGF1ParameterSpec.SHA1,PSpecified.DEFAULT)`.
|
||||||
|
|
||||||
|
| Plaintext encrypted | Field |
|
||||||
|
|---|---|
|
||||||
|
| PAN (digits only) | `encryptedCardNumber` |
|
||||||
|
| CVV | `encryptedCardSecurityCode` |
|
||||||
|
| `YYMM` (year then month) | `encryptedCardExpiry` |
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.pay.pomelopay.com/bin-lookup
|
||||||
|
Content-Type: application/json
|
||||||
|
tenant: bankofmaldives
|
||||||
|
x-api-key: <apiKey>
|
||||||
|
x-tenant-id:
|
||||||
|
|
||||||
|
{
|
||||||
|
"encryptedCardNumber":"<b64>",
|
||||||
|
"encryptedCardSecurityCode":"<b64>",
|
||||||
|
"encryptedCardExpiry":"<b64>",
|
||||||
|
"externalId":"<id>",
|
||||||
|
"cardHolderName":"NAME ON CARD",
|
||||||
|
"encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name
|
||||||
|
"encryptedCardExpiryYear":"28",
|
||||||
|
"encSerialId":"<publicKeyId>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{ "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Rate options → 3-D Secure URL
|
||||||
|
|
||||||
|
All `next-action` calls POST to the merchant API with `Authorization: <pomeloJsKey>`.
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action
|
||||||
|
Authorization: <pomeloJsKey>
|
||||||
|
|
||||||
|
{ "action":"RATE_OPTIONS", "transactionId":"<id>",
|
||||||
|
"cardBrand":"V", "bin8":"42136300", "tokenId":"<tokenId>",
|
||||||
|
"javaEnabled":false, "javascriptEnabled":true, "language":"en-US",
|
||||||
|
"colorDepth":24, "screenHeight":1850, "screenWidth":1080, "tz":-300,
|
||||||
|
"userAgent":"Mozilla/5.0 (Android …; Mobile)" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response `action` values:
|
||||||
|
|
||||||
|
| `action` | Meaning | Do |
|
||||||
|
|---|---|---|
|
||||||
|
| `WAIT` | Processing | Poll (below) |
|
||||||
|
| `POLL` | Keep polling | Poll |
|
||||||
|
| `THREEDS` + `3dsUrl` | Challenge required | Run [§5](#5-3-d-secure-challenge) |
|
||||||
|
| `TRANSACTION_CONFIRMED` | Paid (frictionless) | Done |
|
||||||
|
| `TRANSACTION_FAILED` | Declined | Fail |
|
||||||
|
|
||||||
|
Poll body (every **5 s**, no browser-info):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST …/next-action { "action":"POLL", "transactionId":"<id>" }
|
||||||
|
```
|
||||||
|
|
||||||
|
> In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with
|
||||||
|
> `3dsUrl = …/modirum/render-tds?transactionId=<id>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 3-D Secure challenge (Wibmo ACS)
|
||||||
|
|
||||||
|
A chain of auto-submitting HTML forms. **Only the `render-tds` form and the final gateway /
|
||||||
|
notification forms carry an `action` attribute** — the ACS's channel-picker and OTP forms have
|
||||||
|
no `action`; their JavaScript posts back to the **same creq URL**. So the creq URL (the
|
||||||
|
`render-tds` form's action) is the fallback action for every subsequent form.
|
||||||
|
|
||||||
|
1. **`GET <3dsUrl>`** (`render-tds`) → a form posting `creq` to
|
||||||
|
`https://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>`. Capture that
|
||||||
|
URL as the ACS creq URL.
|
||||||
|
2. **POST creq** → the **channel picker**: radios `destValue ∈ {mobile, email, token}`, plus hidden
|
||||||
|
`creq`, `authMethod`, `otpDest`, `selectChannel`, `otpChannels`, `formReqType`. The BML token /
|
||||||
|
authenticator is the **`token`** channel. Submit:
|
||||||
|
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
|
||||||
|
(keep the hidden `creq` / `otpChannels`).
|
||||||
|
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
|
||||||
|
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
|
||||||
|
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
|
||||||
|
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
|
||||||
|
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
|
||||||
|
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
|
||||||
|
|
||||||
|
Cookies (`__cf_bm`, `_cfuvid`) are set by the ACS and must be carried across these POSTs — the
|
||||||
|
Cloudflare-fronted ACS also requires a browser User-Agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Confirm
|
||||||
|
|
||||||
|
Poll `next-action` until the recorded verdict surfaces:
|
||||||
|
|
||||||
|
| `action` | Result |
|
||||||
|
|---|---|
|
||||||
|
| `TRANSACTION_CONFIRMED` | Success |
|
||||||
|
| `TRANSACTION_FAILED` | Declined |
|
||||||
|
|
||||||
|
The merchant's own backend is also notified out-of-band (e.g.
|
||||||
|
`fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragility — what can break
|
||||||
|
|
||||||
|
This is scraped glue across BML, Pomelo, Wibmo and MPGS. No versioned contract, no sandbox; you
|
||||||
|
learn of breakage from a failed live payment.
|
||||||
|
|
||||||
|
| Area | Breaks when | Symptom |
|
||||||
|
|---|---|---|
|
||||||
|
| **ACS HTML scraping** (most fragile) | Wibmo changes field names (`destValue`/`otpValue`/`creq`), the `"token"` channel label, the error wording, or the form layout | "Unexpected authentication page" / wrong-OTP loop |
|
||||||
|
| **Cloudflare** | `/paynow` or `wibmo.com` adds a JS/managed challenge or TLS-fingerprint check | 403; **not fixable by UA alone** |
|
||||||
|
| **TOTP seed assumption** | The card's 3-D Secure "authenticator" is not the same soft-token TOTP as the BML login; or the card only offers SMS/email OTP | Wrong code submitted; auth fails |
|
||||||
|
| **Pomelo crypto/contract** | OAEP hash change (SHA-1→256), added nonce/timestamp, renamed fields, moved endpoint | `bin-lookup` rejects the card |
|
||||||
|
| **`next-action` states** | New/renamed actions, or browser-info becomes validated | Poll never resolves |
|
||||||
|
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
|
||||||
|
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
|
||||||
|
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
|
||||||
|
|
||||||
|
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
|
||||||
|
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
|
||||||
|
gitignored HARs under `docs/bmlapi/tmp/` as reference fixtures to diff against.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Related:** [QR Payment](13-qr-payment.md) · App side:
|
||||||
|
[Card Verification & Merchant Card Pay](../thijooree/29-card-verification-and-merchant-card-pay.md)
|
||||||
|
|
||||||
|
[← Card Freeze](15-card-freeze.md)
|
||||||
@@ -193,6 +193,7 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
|
|||||||
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
|
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
|
||||||
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
|
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
|
||||||
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
|
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
|
||||||
|
| 16 | [Merchant Card Payment](16-card-payment.md) | Pay a card-only BML Merchant Services link — Pomelo tokenise + 3-D Secure |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[← Profile Picture](06-profile-picture.md)
|
[← Profile Picture](06-profile-picture.md) **Next →** [PayMV QR](08-paymv-qr.md)
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# PayMV QR (Receive)
|
||||||
|
|
||||||
|
> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress).
|
||||||
|
|
||||||
|
Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET api/app/qr/?lang=<lang>&type=p2p&amount=<amount>
|
||||||
|
```
|
||||||
|
|
||||||
|
The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields.
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`.
|
||||||
|
|
||||||
|
## The card image
|
||||||
|
|
||||||
|
A 1240 × 1322 PNG:
|
||||||
|
- Blue `#005DA3` card with a 6 px border.
|
||||||
|
- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right.
|
||||||
|
- A blue panel holding the holder's name and the QR.
|
||||||
|
- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it.
|
||||||
|
- A "MALDIVES NATIONAL QR" footer.
|
||||||
|
|
||||||
|
Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard).
|
||||||
|
|
||||||
|
Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Saved Favourites](07-contacts.md)
|
||||||
@@ -127,6 +127,7 @@ Client Server
|
|||||||
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
||||||
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
||||||
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
||||||
|
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" baseProfile="basic" id="Layer_1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
|
||||||
|
xml:space="preserve">
|
||||||
|
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
|
||||||
|
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
|
||||||
|
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"/>
|
||||||
|
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
|
||||||
|
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
|
||||||
|
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
|
||||||
|
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"/>
|
||||||
|
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
|
||||||
|
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
|
||||||
|
L256,193.98999z"/>
|
||||||
|
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
|
||||||
|
C72,238.87917,85.87916,225,102.99997,225H248z"/>
|
||||||
|
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 "/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |