This commit is contained in:
@@ -91,6 +91,9 @@ dependencies {
|
|||||||
// Biometric authentication
|
// Biometric authentication
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|
||||||
|
// Encrypted SharedPreferences (HCE token store)
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.NFC" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".BasedBankApp"
|
android:name=".BasedBankApp"
|
||||||
@@ -59,6 +62,19 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".nfc.BmlHostCardEmulatorService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||||
|
android:resource="@xml/bml_aid_list" />
|
||||||
|
</service>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ data class BmlQrPayResult(
|
|||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlWalletToken(
|
||||||
|
val token: String,
|
||||||
|
val expiry: String,
|
||||||
|
val appCode: String, // AID hex, e.g. "A0000000031010"
|
||||||
|
val serviceCode: String,
|
||||||
|
val data: String,
|
||||||
|
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
|
||||||
|
)
|
||||||
|
|
||||||
data class BmlForeignLimit(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
val used: Double,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class BmlTapToPayClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches up to [quantity] single-use payment tokens for [cardId].
|
||||||
|
* [otp] is a TOTP code generated from the stored BML OTP seed.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
|
||||||
|
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
|
||||||
|
* 3. POST with otp=TOTP → code 0, payload = token list
|
||||||
|
*/
|
||||||
|
fun fetchTokens(
|
||||||
|
session: BmlSession,
|
||||||
|
cardId: String,
|
||||||
|
otp: String,
|
||||||
|
quantity: Int = 3
|
||||||
|
): List<BmlWalletToken> {
|
||||||
|
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
|
||||||
|
|
||||||
|
// Step 1: initiate
|
||||||
|
val base = JSONObject().apply {
|
||||||
|
put("type", "track2")
|
||||||
|
put("cardid", cardId)
|
||||||
|
put("quantity", quantity)
|
||||||
|
}
|
||||||
|
val step1 = post(session, url, base)
|
||||||
|
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
|
||||||
|
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
|
||||||
|
|
||||||
|
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
|
||||||
|
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
|
||||||
|
val step2 = post(session, url, body2)
|
||||||
|
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
|
||||||
|
|
||||||
|
// Step 3: submit TOTP
|
||||||
|
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
|
||||||
|
val step3 = post(session, url, body3)
|
||||||
|
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
|
||||||
|
|
||||||
|
return parseTokens(step3.optJSONArray("payload"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
|
||||||
|
val req = okhttp3.Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.build()
|
||||||
|
return client.newCall(req).execute().use { resp ->
|
||||||
|
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
|
||||||
|
arr ?: return emptyList()
|
||||||
|
return (0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BmlWalletToken(
|
||||||
|
token = o.getString("token"),
|
||||||
|
expiry = o.getString("expiry"),
|
||||||
|
appCode = o.getString("app_code"),
|
||||||
|
serviceCode = o.getString("service_code"),
|
||||||
|
data = o.optString("data", ""),
|
||||||
|
validUntil = o.optString("valid_until", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package sh.sar.basedbank.nfc
|
||||||
|
|
||||||
|
import android.nfc.cardemulation.HostApduService
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import sh.sar.basedbank.api.bml.BmlWalletToken
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HCE service that emulates a BML contactless payment card.
|
||||||
|
*
|
||||||
|
* Implements the minimal EMV mag-stripe contactless flow:
|
||||||
|
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
|
||||||
|
*
|
||||||
|
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
|
||||||
|
* After READ RECORD is sent the [onTransactionComplete] callback fires.
|
||||||
|
*/
|
||||||
|
class BmlHostCardEmulatorService : HostApduService() {
|
||||||
|
|
||||||
|
private var gpoSent = false
|
||||||
|
|
||||||
|
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
||||||
|
if (commandApdu == null) return SW_UNKNOWN_ERROR
|
||||||
|
val apdu = Apdu(commandApdu)
|
||||||
|
if (apdu.isError) return apdu.errorResponse()
|
||||||
|
|
||||||
|
return when (apdu.ins) {
|
||||||
|
INS_SELECT -> handleSelect(apdu)
|
||||||
|
INS_GPO -> handleGpo()
|
||||||
|
INS_READ -> handleReadRecord()
|
||||||
|
else -> SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeactivated(reason: Int) {
|
||||||
|
if (!gpoSent) onTransactionComplete?.invoke(false)
|
||||||
|
gpoSent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── APDU handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun handleSelect(apdu: Apdu): ByteArray {
|
||||||
|
val data = apdu.data ?: return SW_UNKNOWN_ERROR
|
||||||
|
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||||
|
|
||||||
|
return when {
|
||||||
|
data.contentEquals(PPSE_BYTES) -> {
|
||||||
|
// SELECT PPSE
|
||||||
|
val resp = buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01")
|
||||||
|
hexToBytes(resp)
|
||||||
|
}
|
||||||
|
data.contentEquals(hexToBytes(token.appCode)) -> {
|
||||||
|
// SELECT AID
|
||||||
|
val resp = buildSelectAidResponse(token.appCode, applicationLabel(token.appCode))
|
||||||
|
hexToBytes(resp)
|
||||||
|
}
|
||||||
|
else -> SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGpo(): ByteArray {
|
||||||
|
gpoSent = true
|
||||||
|
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
|
||||||
|
val miscData = "008008010100"
|
||||||
|
val body = tlv("80", miscData)
|
||||||
|
return hexToBytes(body + SW_OK_HEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReadRecord(): ByteArray {
|
||||||
|
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||||
|
val track2 = buildTrack2(token)
|
||||||
|
val body = tlv("70", tlv("57", track2))
|
||||||
|
val response = hexToBytes(body + SW_OK_HEX)
|
||||||
|
onTransactionComplete?.invoke(true)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TLV / APDU response builders ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
|
||||||
|
val priorityTlv = tlv("87", priority) // tag 87
|
||||||
|
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
|
||||||
|
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
|
||||||
|
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
|
||||||
|
val inner = tlv("BF0C", appEntry) // tag BF0C
|
||||||
|
val propTemplate = tlv("A5", inner) // tag A5
|
||||||
|
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
|
||||||
|
return fci + SW_OK_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSelectAidResponse(aid: String, label: String): String {
|
||||||
|
val aidTlv = tlv("84", aid) // tag 84
|
||||||
|
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
|
||||||
|
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
|
||||||
|
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
|
||||||
|
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
|
||||||
|
return fci + SW_OK_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTrack2(token: BmlWalletToken): String {
|
||||||
|
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
|
||||||
|
if (t2.length % 2 != 0) t2 += "F"
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
|
||||||
|
private fun tlv(tagHex: String, dataHex: String): String {
|
||||||
|
val lenBytes = dataHex.length / 2
|
||||||
|
val lenHex = when {
|
||||||
|
lenBytes <= 0x7F -> lenBytes.toHexByte()
|
||||||
|
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
|
||||||
|
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
|
||||||
|
}
|
||||||
|
return tagHex + lenHex + dataHex
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
|
||||||
|
|
||||||
|
private fun asciiToHex(s: String): String =
|
||||||
|
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
|
||||||
|
|
||||||
|
private fun hexToBytes(hex: String): ByteArray {
|
||||||
|
val s = hex.uppercase()
|
||||||
|
return ByteArray(s.length / 2) { i ->
|
||||||
|
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── APDU parser ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class Apdu(raw: ByteArray) {
|
||||||
|
val isError: Boolean
|
||||||
|
val ins: Int
|
||||||
|
val data: ByteArray?
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (raw.size < 4) {
|
||||||
|
isError = true; ins = -1; data = null
|
||||||
|
} else {
|
||||||
|
isError = false
|
||||||
|
ins = raw[1].toInt() and 0xFF
|
||||||
|
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
|
||||||
|
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun errorResponse() = SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BmlHCE"
|
||||||
|
|
||||||
|
private const val INS_SELECT = 0xA4
|
||||||
|
private const val INS_GPO = 0xA8
|
||||||
|
private const val INS_READ = 0xB2
|
||||||
|
|
||||||
|
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
|
||||||
|
private val PPSE_BYTES = byteArrayOf(
|
||||||
|
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val SW_OK_HEX = "9000"
|
||||||
|
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
|
||||||
|
|
||||||
|
@Volatile var activeToken: BmlWalletToken? = null
|
||||||
|
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
fun setToken(token: BmlWalletToken) { activeToken = token }
|
||||||
|
fun clearToken() { activeToken = null }
|
||||||
|
|
||||||
|
fun applicationLabel(aidHex: String): String = when {
|
||||||
|
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
|
||||||
|
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
|
||||||
|
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
|
||||||
|
else -> "BML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package sh.sar.basedbank.nfc
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlWalletToken
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted local store for a pool of single-use HCE payment tokens.
|
||||||
|
* Tokens are consumed one per NFC transaction.
|
||||||
|
*/
|
||||||
|
class HceTokenStore(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = try {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"hce_token_store",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
context.getSharedPreferences("hce_token_store_fallback", Context.MODE_PRIVATE)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveTokens(tokens: List<BmlWalletToken>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
tokens.forEach { t ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("token", t.token)
|
||||||
|
put("expiry", t.expiry)
|
||||||
|
put("app_code", t.appCode)
|
||||||
|
put("service_code", t.serviceCode)
|
||||||
|
put("data", t.data)
|
||||||
|
put("valid_until", t.validUntil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prefs.edit().putString("tokens", arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove and return the next token, or null if pool is empty / all expired. */
|
||||||
|
fun popToken(): BmlWalletToken? {
|
||||||
|
val raw = prefs.getString("tokens", null) ?: return null
|
||||||
|
val arr = try { JSONArray(raw) } catch (_: Exception) { return null }
|
||||||
|
if (arr.length() == 0) return null
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
var found: BmlWalletToken? = null
|
||||||
|
var foundIndex = -1
|
||||||
|
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val validUntil = o.optString("valid_until", "")
|
||||||
|
if (validUntil.isNotBlank() && isExpired(validUntil, now)) continue
|
||||||
|
found = BmlWalletToken(
|
||||||
|
token = o.getString("token"),
|
||||||
|
expiry = o.getString("expiry"),
|
||||||
|
appCode = o.getString("app_code"),
|
||||||
|
serviceCode = o.getString("service_code"),
|
||||||
|
data = o.optString("data", ""),
|
||||||
|
validUntil = validUntil
|
||||||
|
)
|
||||||
|
foundIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found == null || foundIndex < 0) {
|
||||||
|
prefs.edit().remove("tokens").apply()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove consumed token
|
||||||
|
val remaining = JSONArray()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
if (i != foundIndex) remaining.put(arr.getJSONObject(i))
|
||||||
|
}
|
||||||
|
prefs.edit().putString("tokens", remaining.toString()).apply()
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remainingCount(): Int {
|
||||||
|
val raw = prefs.getString("tokens", null) ?: return 0
|
||||||
|
return try { JSONArray(raw).length() } catch (_: Exception) { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasTokens(): Boolean = remainingCount() > 0
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().remove("tokens").apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "YYYY-MM-DD HH:mm:ss.SSS" → check if past now */
|
||||||
|
private fun isExpired(validUntil: String, nowMs: Long): Boolean = try {
|
||||||
|
val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
|
||||||
|
fmt.timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||||
|
val d = fmt.parse(validUntil) ?: return false
|
||||||
|
d.time < nowMs
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
@@ -24,12 +24,30 @@ import androidx.fragment.app.activityViewModels
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.PagerSnapHelper
|
import androidx.recyclerview.widget.PagerSnapHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.view.Gravity
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlTapToPayClient
|
||||||
|
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||||
|
import sh.sar.basedbank.nfc.HceTokenStore
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
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.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -45,6 +63,8 @@ class CardsFragment : Fragment() {
|
|||||||
private var cardWidth: Int = 0
|
private var cardWidth: Int = 0
|
||||||
private var pendingQrAccountNumber: String? = null
|
private var pendingQrAccountNumber: String? = null
|
||||||
private var isManageMode: Boolean = false
|
private var isManageMode: Boolean = false
|
||||||
|
private var isTapMode: Boolean = false
|
||||||
|
private var tapAnimView: NfcTapAnimationView? = null
|
||||||
|
|
||||||
// Carousel snapshot captured on enter, used to reverse the exit animation
|
// Carousel snapshot captured on enter, used to reverse the exit animation
|
||||||
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
|
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
|
||||||
@@ -136,7 +156,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
|
|
||||||
// Swipe-down on the manage card to dismiss manage mode
|
// Swipe-down on the manage card to dismiss manage mode
|
||||||
binding.manageCardView.root.setOnTouchListener { _, event ->
|
binding.manageCardView.root.setOnTouchListener { _, event ->
|
||||||
if (!isManageMode) return@setOnTouchListener false
|
if (!isManageMode && !isTapMode) return@setOnTouchListener false
|
||||||
val mgr = binding.manageCardView.root
|
val mgr = binding.manageCardView.root
|
||||||
when (event.action) {
|
when (event.action) {
|
||||||
android.view.MotionEvent.ACTION_DOWN -> {
|
android.view.MotionEvent.ACTION_DOWN -> {
|
||||||
@@ -163,7 +183,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
|
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
|
||||||
swipeIsDragging = false
|
swipeIsDragging = false
|
||||||
if (dy > 130f) {
|
if (dy > 130f) {
|
||||||
setManageMode(false)
|
if (isTapMode) setTapMode(false) else setManageMode(false)
|
||||||
} else {
|
} else {
|
||||||
// Snap back
|
// Snap back
|
||||||
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
|
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
|
||||||
@@ -192,8 +212,17 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
binding.btnTapToPay.isEnabled = nfcAvailable
|
binding.btnTapToPay.isEnabled = nfcAvailable
|
||||||
binding.btnTapToPay.setOnClickListener {
|
binding.btnTapToPay.setOnClickListener {
|
||||||
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
||||||
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
if (item is CardItem.Mib) {
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
val bmlItem = item as CardItem.Bml
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||||
|
showBiometricPromptForTap(bmlItem)
|
||||||
|
} else {
|
||||||
|
setTapMode(true, bmlItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val wip = View.OnClickListener {
|
val wip = View.OnClickListener {
|
||||||
@@ -378,6 +407,250 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
.start()
|
.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||||
|
isTapMode = enabled
|
||||||
|
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||||
|
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBiometricPromptForTap(item: CardItem.Bml) {
|
||||||
|
val bmgr = BiometricManager.from(requireContext())
|
||||||
|
if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
|
||||||
|
setTapMode(true, item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
setTapMode(true, item)
|
||||||
|
}
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { }
|
||||||
|
})
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(getString(R.string.card_pay_nfc))
|
||||||
|
.setSubtitle(item.account.accountBriefName)
|
||||||
|
.setNegativeButtonText(getString(R.string.cancel))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterTapMode(item: CardItem.Bml) {
|
||||||
|
// Bind card data to the shared manage card view
|
||||||
|
val cv = binding.manageCardView
|
||||||
|
cv.tvCardOwner.text = item.account.accountBriefName
|
||||||
|
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||||
|
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
|
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
|
||||||
|
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||||
|
|
||||||
|
// Snapshot carousel card position before layout changes (for animation)
|
||||||
|
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||||
|
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
|
||||||
|
val srcView = lm?.findViewByPosition(currentCardPosition)
|
||||||
|
val srcLoc = IntArray(2).also {
|
||||||
|
srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] }
|
||||||
|
}
|
||||||
|
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
|
||||||
|
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
|
||||||
|
carouselCardLayoutTop = srcScreenTop
|
||||||
|
carouselCardCenterX = srcCenterX
|
||||||
|
carouselTextLayoutTop = textSrcScreenTop
|
||||||
|
|
||||||
|
// Apply layout changes
|
||||||
|
binding.btnManageCard.visibility = View.GONE
|
||||||
|
binding.topSpacer.visibility = View.GONE
|
||||||
|
binding.rvCards.visibility = View.GONE
|
||||||
|
binding.pageIndicator.visibility = View.GONE
|
||||||
|
binding.divider.visibility = View.GONE
|
||||||
|
binding.llPayButtons.visibility = View.GONE
|
||||||
|
binding.llManageButtons.visibility = View.GONE
|
||||||
|
binding.llDefaultCardRow.visibility = View.GONE
|
||||||
|
binding.manageCardView.root.visibility = View.VISIBLE
|
||||||
|
binding.flTapMode.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Build tap mode content: animation view + cancel button
|
||||||
|
binding.flTapMode.removeAllViews()
|
||||||
|
val animView = NfcTapAnimationView(requireContext())
|
||||||
|
tapAnimView = animView
|
||||||
|
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
val cancelBtn = MaterialButton(requireContext(), null,
|
||||||
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
|
).apply { setText(R.string.cancel); setOnClickListener { setTapMode(false) } }
|
||||||
|
|
||||||
|
val cancelWrapper = LinearLayout(requireContext()).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
|
setPadding(0, 0, 0, (24 * dp).toInt())
|
||||||
|
addView(cancelBtn)
|
||||||
|
}
|
||||||
|
val container = LinearLayout(requireContext()).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
addView(View(requireContext()).apply { // spacer pushes content below card
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||||
|
})
|
||||||
|
addView(animView.apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
||||||
|
})
|
||||||
|
addView(cancelWrapper.apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
binding.flTapMode.addView(container)
|
||||||
|
|
||||||
|
// Animate card up from carousel position (same as manage mode)
|
||||||
|
binding.contentLayout.doOnNextLayout {
|
||||||
|
val mgr = binding.manageCardView.root
|
||||||
|
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||||
|
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
|
||||||
|
|
||||||
|
mgr.pivotX = mgr.width / 2f
|
||||||
|
mgr.pivotY = 0f
|
||||||
|
mgr.scaleX = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
|
||||||
|
mgr.scaleY = mgr.scaleX
|
||||||
|
mgr.translationX = srcCenterX - dstCenterX
|
||||||
|
mgr.translationY = srcScreenTop - dstTop
|
||||||
|
|
||||||
|
mgr.animate()
|
||||||
|
.scaleX(1f).scaleY(1f)
|
||||||
|
.translationX(0f).translationY(0f)
|
||||||
|
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
|
||||||
|
|
||||||
|
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
binding.tvSelectedCardType.translationY = textSrcScreenTop - (textDstLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
binding.tvSelectedCardType.animate()
|
||||||
|
.translationY(0f)
|
||||||
|
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAndArmToken(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exitTapMode() {
|
||||||
|
tapAnimView?.stopAnimation()
|
||||||
|
tapAnimView = null
|
||||||
|
BmlHostCardEmulatorService.clearToken()
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||||
|
|
||||||
|
binding.manageCardView.root.animate().cancel()
|
||||||
|
binding.tvSelectedCardType.animate().cancel()
|
||||||
|
|
||||||
|
val mgr = binding.manageCardView.root
|
||||||
|
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||||
|
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||||
|
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
|
||||||
|
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
|
||||||
|
|
||||||
|
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
|
||||||
|
|
||||||
|
mgr.pivotX = mgr.width / 2f
|
||||||
|
mgr.pivotY = 0f
|
||||||
|
|
||||||
|
mgr.animate()
|
||||||
|
.scaleX(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
|
||||||
|
.scaleY(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
|
||||||
|
.translationX(carouselCardCenterX - mgrLayoutCenterX)
|
||||||
|
.translationY(carouselCardLayoutTop - mgrLayoutTop)
|
||||||
|
.setDuration(320)
|
||||||
|
.setInterpolator(AccelerateInterpolator())
|
||||||
|
.withEndAction {
|
||||||
|
mgr.scaleX = 1f; mgr.scaleY = 1f
|
||||||
|
mgr.translationX = 0f; mgr.translationY = 0f
|
||||||
|
mgr.visibility = View.GONE
|
||||||
|
binding.tvSelectedCardType.translationY = 0f
|
||||||
|
binding.flTapMode.visibility = View.GONE
|
||||||
|
binding.flTapMode.removeAllViews()
|
||||||
|
binding.btnManageCard.visibility = View.VISIBLE
|
||||||
|
binding.topSpacer.visibility = View.VISIBLE
|
||||||
|
binding.rvCards.visibility = View.VISIBLE
|
||||||
|
binding.divider.visibility = View.VISIBLE
|
||||||
|
binding.llPayButtons.visibility = View.VISIBLE
|
||||||
|
buildDots(cards.size, currentCardPosition)
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
|
||||||
|
binding.tvSelectedCardType.animate()
|
||||||
|
.translationY(carouselTextLayoutTop - textLayoutTop)
|
||||||
|
.setDuration(320)
|
||||||
|
.setInterpolator(AccelerateInterpolator())
|
||||||
|
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAndArmToken(item: CardItem.Bml) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val tokenStore = HceTokenStore(requireContext())
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
var token = withContext(Dispatchers.IO) { tokenStore.popToken() }
|
||||||
|
|
||||||
|
if (token == null) {
|
||||||
|
val loginId = item.account.loginTag.removePrefix("bml_")
|
||||||
|
val session = app.bmlSessionFor(item.account)
|
||||||
|
val otpSeed = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
|
||||||
|
if (session == null || otpSeed == null) {
|
||||||
|
if (isTapMode) {
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
if (session == null) getString(R.string.transfer_session_unavailable)
|
||||||
|
else "OTP unavailable",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
setTapMode(false)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val otp = Totp.generate(otpSeed)
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlTapToPayClient().fetchTokens(session, item.account.internalId, otp) }
|
||||||
|
}
|
||||||
|
val fetched = result.getOrNull()
|
||||||
|
if (fetched.isNullOrEmpty()) {
|
||||||
|
if (isTapMode) {
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to get payment token",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
setTapMode(false)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
tokenStore.saveTokens(fetched)
|
||||||
|
token = tokenStore.popToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTapMode) return@launch // user cancelled while we were fetching
|
||||||
|
|
||||||
|
val t = token ?: run {
|
||||||
|
if (isTapMode) {
|
||||||
|
Toast.makeText(requireContext(), "No valid payment tokens", Toast.LENGTH_SHORT).show()
|
||||||
|
setTapMode(false)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
BmlHostCardEmulatorService.setToken(t)
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = { success ->
|
||||||
|
view?.post {
|
||||||
|
if (!isTapMode) return@post
|
||||||
|
setTapMode(false)
|
||||||
|
if (success) Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun rebuildCards() {
|
private fun rebuildCards() {
|
||||||
// Remember which card is currently selected by identity so we can restore position after reorder
|
// Remember which card is currently selected by identity so we can restore position after reorder
|
||||||
val currentCard = cards.getOrNull(currentCardPosition)
|
val currentCard = cards.getOrNull(currentCardPosition)
|
||||||
@@ -433,7 +706,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDots(count: Int, selected: Int) {
|
private fun buildDots(count: Int, selected: Int) {
|
||||||
if (isManageMode) return
|
if (isManageMode || isTapMode) return
|
||||||
binding.pageIndicator.removeAllViews()
|
binding.pageIndicator.removeAllViews()
|
||||||
if (count <= 1) {
|
if (count <= 1) {
|
||||||
binding.pageIndicator.visibility = View.GONE
|
binding.pageIndicator.visibility = View.GONE
|
||||||
@@ -469,6 +742,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun onBackPressed(): Boolean {
|
fun onBackPressed(): Boolean {
|
||||||
|
if (isTapMode) {
|
||||||
|
setTapMode(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (isManageMode) {
|
if (isManageMode) {
|
||||||
setManageMode(false)
|
setManageMode(false)
|
||||||
return true
|
return true
|
||||||
@@ -476,12 +753,24 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
if (isTapMode) {
|
||||||
|
BmlHostCardEmulatorService.clearToken()
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.nav_pay_with_card)
|
requireActivity().title = getString(R.string.nav_pay_with_card)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
tapAnimView?.stopAnimation()
|
||||||
|
tapAnimView = null
|
||||||
|
BmlHostCardEmulatorService.clearToken()
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
@@ -543,6 +832,104 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NFC animation view ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class NfcTapAnimationView(context: Context) : View(context) {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||||
|
duration = 1600
|
||||||
|
repeatCount = ValueAnimator.INFINITE
|
||||||
|
repeatMode = ValueAnimator.RESTART
|
||||||
|
addUpdateListener { invalidate() }
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopAnimation() = animator.cancel()
|
||||||
|
|
||||||
|
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 progress = animator.animatedFraction
|
||||||
|
val cx = w / 2f; val cy = h / 2f - 20 * dp
|
||||||
|
|
||||||
|
val colorOnSurface = MaterialColors.getColor(this,
|
||||||
|
com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||||
|
val colorPrimary = MaterialColors.getColor(this,
|
||||||
|
com.google.android.material.R.attr.colorPrimary, android.graphics.Color.BLUE)
|
||||||
|
val colorSurfaceVariant = MaterialColors.getColor(this,
|
||||||
|
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
|
||||||
|
|
||||||
|
// Phone (left of center)
|
||||||
|
val phoneW = 36 * dp; val phoneH = 62 * dp
|
||||||
|
val phoneX = cx - 72 * dp - phoneW; val phoneY = cy - phoneH / 2f
|
||||||
|
|
||||||
|
// POS terminal (right of center)
|
||||||
|
val posW = 30 * dp; val posH = 50 * dp
|
||||||
|
val posX = cx + 72 * dp; val posY = cy - posH / 2f
|
||||||
|
|
||||||
|
// Phone body
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
|
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 6 * dp, 6 * dp, paint)
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||||
|
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 6 * dp, 6 * dp, paint)
|
||||||
|
|
||||||
|
// Phone screen
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||||
|
canvas.drawRoundRect(phoneX + 3 * dp, phoneY + 8 * dp,
|
||||||
|
phoneX + phoneW - 3 * dp, phoneY + phoneH - 12 * dp, 3 * dp, 3 * dp, paint)
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// Static NFC arcs on the right side of phone
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorPrimary
|
||||||
|
val arcOriginX = phoneX + phoneW
|
||||||
|
for (i in 1..3) {
|
||||||
|
val r = i * 10 * dp
|
||||||
|
paint.alpha = 220 - i * 50
|
||||||
|
canvas.drawArc(RectF(arcOriginX - r, cy - r, arcOriginX + r, cy + r),
|
||||||
|
-70f, 140f, false, paint)
|
||||||
|
}
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// POS terminal body
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
|
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 5 * dp, 5 * dp, paint)
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||||
|
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 5 * dp, 5 * dp, paint)
|
||||||
|
|
||||||
|
// POS screen
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||||
|
canvas.drawRoundRect(posX + 3 * dp, posY + 4 * dp,
|
||||||
|
posX + posW - 3 * dp, posY + posH * 0.45f, 3 * dp, 3 * dp, paint)
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// POS card slot
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * dp; paint.color = colorOnSurface
|
||||||
|
canvas.drawLine(posX + 4 * dp, posY + posH * 0.72f, posX + posW - 4 * dp, posY + posH * 0.72f, paint)
|
||||||
|
|
||||||
|
// Animated NFC rings travelling from phone toward POS
|
||||||
|
val gapStart = arcOriginX + 28 * dp
|
||||||
|
val gapEnd = posX - 4 * dp
|
||||||
|
val midX = (gapStart + gapEnd) / 2f
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp
|
||||||
|
for (i in 0..2) {
|
||||||
|
val p = ((progress + i / 3f) % 1f)
|
||||||
|
val r = p * (gapEnd - gapStart) / 2f + 6 * dp
|
||||||
|
paint.color = colorPrimary; paint.alpha = ((1f - p) * 200).toInt().coerceIn(0, 255)
|
||||||
|
canvas.drawArc(RectF(midX - r, cy - r, midX + r, cy + r), -80f, 160f, false, paint)
|
||||||
|
}
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// Label
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 160
|
||||||
|
paint.textSize = 14 * dp; paint.textAlign = Paint.Align.CENTER
|
||||||
|
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, cy + 60 * dp, paint)
|
||||||
|
paint.alpha = 255; paint.textAlign = Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
||||||
"51" -> "cards/mib/faisa_card.png"
|
"51" -> "cards/mib/faisa_card.png"
|
||||||
|
|||||||
@@ -93,6 +93,7 @@
|
|||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<View
|
<View
|
||||||
|
android:id="@+id/divider"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1dp"
|
android:layout_height="1dp"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
@@ -242,6 +243,13 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Tap-to-pay overlay: shown in tap mode, sits above contentLayout -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/flTapMode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/loadingView"
|
android:id="@+id/loadingView"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<aid-filter xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Contactless PPSE directory -->
|
||||||
|
<aid-group android:description="@string/app_name"
|
||||||
|
android:category="payment">
|
||||||
|
|
||||||
|
<!-- PPSE: 2PAY.SYS.DDF01 -->
|
||||||
|
<aid-filter android:name="325041592E5359532E4444463031" />
|
||||||
|
|
||||||
|
<!-- Visa -->
|
||||||
|
<aid-filter android:name="A0000000031010" />
|
||||||
|
|
||||||
|
<!-- Mastercard -->
|
||||||
|
<aid-filter android:name="A0000000041010" />
|
||||||
|
|
||||||
|
<!-- Amex -->
|
||||||
|
<aid-filter android:name="A000000025" />
|
||||||
|
|
||||||
|
</aid-group>
|
||||||
|
|
||||||
|
</aid-filter>
|
||||||
Reference in New Issue
Block a user