new nfc icon, hide cards, removed offline nfc payments
Auto Tag on Version Change / check-version (push) Failing after 12m50s
Auto Tag on Version Change / check-version (push) Failing after 12m50s
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user