new nfc icon, hide cards, removed offline nfc payments
Auto Tag on Version Change / check-version (push) Failing after 12m50s

This commit is contained in:
2026-05-29 16:39:58 +05:00
parent 2df162c09e
commit ee5ecdaa18
15 changed files with 252 additions and 329 deletions
@@ -56,8 +56,9 @@ class BmlHostCardEmulatorService : HostApduService() {
}
private fun launchPromptActivity() {
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
val intent = Intent("sh.sar.basedbank.TAP_TO_PAY").apply {
setPackage(applicationContext.packageName)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
@@ -2,166 +2,19 @@ package sh.sar.basedbank.nfc
import android.content.Intent
import android.os.Bundle
import android.view.Gravity
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import sh.sar.basedbank.MainActivity
/**
* Launched by [BmlHostCardEmulatorService] when the phone is tapped against a POS terminal
* but no payment token is armed. Handles biometric auth and token loading from the local store,
* then arms the service so the user can tap again to complete the payment.
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
*/
class BmlTapToPayActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
prepare()
}
// singleTop: if already showing, just ignore the re-launch
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
}
private fun prepare() {
val store = HceTokenStore(this)
if (!store.hasTokens()) {
Toast.makeText(this,
"Open the app and tap Tap to Pay first to load payment tokens",
Toast.LENGTH_LONG).show()
finish()
return
}
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val biometricEnabled = prefs.getBoolean("biometrics_transfer_confirm", false)
if (biometricEnabled) {
val bmgr = BiometricManager.from(this)
if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) {
showBiometricPrompt(store)
return
}
}
armAndShowReady(store)
}
private fun showBiometricPrompt(store: HceTokenStore) {
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(this),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
armAndShowReady(store)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
finish()
}
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle(getString(R.string.card_pay_nfc))
.setSubtitle(getString(R.string.app_name))
.setNegativeButtonText(getString(R.string.cancel))
.build()
)
}
private fun armAndShowReady(store: HceTokenStore) {
val token = store.popToken() ?: run {
Toast.makeText(this,
"No valid payment tokens — please open the app",
Toast.LENGTH_LONG).show()
finish()
return
}
BmlHostCardEmulatorService.setToken(token)
BmlHostCardEmulatorService.onTransactionComplete = { success ->
runOnUiThread {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
if (success) Toast.makeText(this, "Payment complete", Toast.LENGTH_SHORT).show()
finish()
}
}
showReadyLayout()
}
private fun showReadyLayout() {
val dp = resources.displayMetrics.density
val colorSurface = MaterialColors.getColor(window.decorView,
com.google.android.material.R.attr.colorSurface, 0xFF1C1B1F.toInt())
val colorOnSurface = MaterialColors.getColor(window.decorView,
com.google.android.material.R.attr.colorOnSurface, 0xFFFFFFFF.toInt())
val colorOnSurfaceVariant = MaterialColors.getColor(window.decorView,
com.google.android.material.R.attr.colorOnSurfaceVariant, 0xFFCAC4D0.toInt())
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(colorSurface)
setPadding((32 * dp).toInt(), (64 * dp).toInt(), (32 * dp).toInt(), (64 * dp).toInt())
}
root.addView(ImageView(this).apply {
setImageResource(R.drawable.ic_nfc)
setColorFilter(colorOnSurface)
layoutParams = LinearLayout.LayoutParams((72 * dp).toInt(), (72 * dp).toInt()).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = (32 * dp).toInt()
}
startActivity(Intent(this, MainActivity::class.java).apply {
action = "sh.sar.basedbank.TAP_TO_PAY"
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
root.addView(TextView(this).apply {
text = getString(R.string.card_pay_nfc)
textSize = 22f
gravity = Gravity.CENTER
setTextColor(colorOnSurface)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = (8 * dp).toInt() }
})
root.addView(TextView(this).apply {
text = "Hold your phone near the payment terminal"
textSize = 14f
gravity = Gravity.CENTER
setTextColor(colorOnSurfaceVariant)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { bottomMargin = (48 * dp).toInt() }
})
root.addView(MaterialButton(this, null,
com.google.android.material.R.attr.materialButtonOutlinedStyle
).apply {
text = getString(R.string.cancel)
setOnClickListener {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
finish()
}
})
setContentView(root)
}
override fun onDestroy() {
super.onDestroy()
// If the activity is killed without completing, clean up the armed token
if (isFinishing && BmlHostCardEmulatorService.activeToken != null) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
}
finish()
}
}
@@ -1,105 +0,0 @@
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 }
}