forked from shihaam/thijooree
security: encrypt credentials, caches, and harden lock screen
This commit is contained in:
@@ -9,6 +9,7 @@ import sh.sar.basedbank.api.mib.MibAccount
|
|||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.MibSession
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
class BasedBankApp : Application() {
|
class BasedBankApp : Application() {
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ class BasedBankApp : Application() {
|
|||||||
val mibMutex = Mutex()
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
val mibLoginFlow by lazy {
|
val mibLoginFlow by lazy {
|
||||||
MibLoginFlow(getSharedPreferences("mib_prefs", MODE_PRIVATE)).also { flow ->
|
MibLoginFlow(CredentialStore(this)).also { flow ->
|
||||||
flow.onSessionRefreshed = { session, profiles ->
|
flow.onSessionRefreshed = { session, profiles ->
|
||||||
mibSession = session
|
mibSession = session
|
||||||
mibProfiles = profiles
|
mibProfiles = profiles
|
||||||
|
|||||||
@@ -5,14 +5,23 @@ import android.os.Bundle
|
|||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
class LockActivity : AppCompatActivity() {
|
class LockActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -22,6 +31,15 @@ class LockActivity : AppCompatActivity() {
|
|||||||
private lateinit var salt: String
|
private lateinit var salt: String
|
||||||
private lateinit var storedHash: String
|
private lateinit var storedHash: String
|
||||||
private var biometricsEnabled = false
|
private var biometricsEnabled = false
|
||||||
|
private var isLegacyFormat = false
|
||||||
|
private var isVerifying = false
|
||||||
|
|
||||||
|
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val MAX_ATTEMPTS = 5
|
||||||
|
private const val LOCKOUT_MS = 30_000L
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
@@ -30,10 +48,20 @@ class LockActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||||
salt = prefs.getString("security_salt", "") ?: ""
|
|
||||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
|
||||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
|
||||||
|
// Try new encrypted format first; fall back to legacy SHA-256
|
||||||
|
val stored = CredentialStore(this).loadSecurityHash()
|
||||||
|
if (stored != null) {
|
||||||
|
salt = stored.first
|
||||||
|
storedHash = stored.second
|
||||||
|
isLegacyFormat = false
|
||||||
|
} else {
|
||||||
|
salt = prefs.getString("security_salt", "") ?: ""
|
||||||
|
storedHash = prefs.getString("security_hash", "") ?: ""
|
||||||
|
isLegacyFormat = true
|
||||||
|
}
|
||||||
|
|
||||||
if (method == "pin") {
|
if (method == "pin") {
|
||||||
binding.viewPin.visibility = View.VISIBLE
|
binding.viewPin.visibility = View.VISIBLE
|
||||||
buildNumpad()
|
buildNumpad()
|
||||||
@@ -48,6 +76,12 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (biometricsEnabled) triggerBiometric()
|
if (biometricsEnabled) triggerBiometric()
|
||||||
|
|
||||||
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
|
override fun handleOnBackPressed() {
|
||||||
|
moveTaskToBack(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildNumpad() {
|
private fun buildNumpad() {
|
||||||
@@ -92,6 +126,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun handleKey(key: String) {
|
private fun handleKey(key: String) {
|
||||||
|
if (isVerifying) return
|
||||||
when (key) {
|
when (key) {
|
||||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||||
@@ -105,13 +140,21 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun verifyPin() {
|
private fun verifyPin() {
|
||||||
|
if (checkAndShowLockout()) return
|
||||||
val entered = pinDigits.joinToString("")
|
val entered = pinDigits.joinToString("")
|
||||||
if (verify(entered)) {
|
pinDigits.clear()
|
||||||
proceed()
|
updateDots()
|
||||||
} else {
|
isVerifying = true
|
||||||
binding.tvLockPinDots.text = getString(R.string.unlock_failed)
|
lifecycleScope.launch {
|
||||||
pinDigits.clear()
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
binding.root.postDelayed({ updateDots() }, 1200)
|
isVerifying = false
|
||||||
|
if (ok) {
|
||||||
|
migrateIfNeeded(entered)
|
||||||
|
resetFailures()
|
||||||
|
proceed()
|
||||||
|
} else {
|
||||||
|
showFailure()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,18 +164,82 @@ class LockActivity : AppCompatActivity() {
|
|||||||
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
|
binding.tvPatternHint.text = getString(R.string.pattern_min_dots)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (verify(pattern.joinToString(""))) {
|
if (checkAndShowLockout()) {
|
||||||
proceed()
|
|
||||||
} else {
|
|
||||||
binding.lockPatternView.showError()
|
binding.lockPatternView.showError()
|
||||||
binding.tvPatternHint.text = getString(R.string.unlock_failed)
|
return
|
||||||
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
|
}
|
||||||
|
val entered = pattern.joinToString("")
|
||||||
|
isVerifying = true
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
|
isVerifying = false
|
||||||
|
if (ok) {
|
||||||
|
migrateIfNeeded(entered)
|
||||||
|
resetFailures()
|
||||||
|
proceed()
|
||||||
|
} else {
|
||||||
|
binding.lockPatternView.showError()
|
||||||
|
val msg = failureMessage()
|
||||||
|
binding.tvPatternHint.text = msg
|
||||||
|
binding.root.postDelayed({ binding.tvPatternHint.text = "" }, 1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true and shows the lockout message if currently locked out. */
|
||||||
|
private fun checkAndShowLockout(): Boolean {
|
||||||
|
val remaining = lockoutRemainingMs()
|
||||||
|
if (remaining <= 0) return false
|
||||||
|
val secs = ((remaining + 999L) / 1000L).toInt()
|
||||||
|
val msg = getString(R.string.unlock_locked_out, secs)
|
||||||
|
binding.tvLockPinDots.text = msg
|
||||||
|
binding.root.postDelayed({ updateDots() }, remaining)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showFailure() {
|
||||||
|
val msg = failureMessage()
|
||||||
|
binding.tvLockPinDots.text = msg
|
||||||
|
binding.root.postDelayed({ updateDots() }, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failureMessage(): String {
|
||||||
|
val fails = incrementFailures()
|
||||||
|
val left = MAX_ATTEMPTS - fails
|
||||||
|
return if (left <= 0) {
|
||||||
|
val secs = (LOCKOUT_MS / 1000L).toInt()
|
||||||
|
getString(R.string.unlock_locked_out, secs)
|
||||||
|
} else {
|
||||||
|
getString(R.string.unlock_attempts_remaining, left)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun verify(input: String): Boolean {
|
private fun verify(input: String): Boolean {
|
||||||
val hash = sha256(salt + input)
|
if (storedHash.isBlank()) return false
|
||||||
return hash == storedHash
|
return if (isLegacyFormat) {
|
||||||
|
sha256Legacy(salt + input) == storedHash
|
||||||
|
} else {
|
||||||
|
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||||
|
pbkdf2(input, saltBytes) == storedHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On the first successful unlock after legacy SHA-256 format is detected,
|
||||||
|
* transparently migrate to PBKDF2 + CredentialStore.
|
||||||
|
*/
|
||||||
|
private fun migrateIfNeeded(input: String) {
|
||||||
|
if (!isLegacyFormat) return
|
||||||
|
try {
|
||||||
|
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
|
val newHash = pbkdf2(input, newSalt)
|
||||||
|
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
|
||||||
|
CredentialStore(this).saveSecurityHash(saltB64, newHash)
|
||||||
|
// Remove legacy plaintext fields
|
||||||
|
getSharedPreferences("prefs", MODE_PRIVATE).edit()
|
||||||
|
.remove("security_salt").remove("security_hash").apply()
|
||||||
|
isLegacyFormat = false
|
||||||
|
} catch (_: Exception) { /* migration will retry next unlock */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerBiometric() {
|
private fun triggerBiometric() {
|
||||||
@@ -145,6 +252,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
ContextCompat.getMainExecutor(this),
|
ContextCompat.getMainExecutor(this),
|
||||||
object : BiometricPrompt.AuthenticationCallback() {
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
resetFailures()
|
||||||
proceed()
|
proceed()
|
||||||
}
|
}
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
@@ -166,11 +274,43 @@ class LockActivity : AppCompatActivity() {
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
|
// ── Brute-force tracking ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun incrementFailures(): Int {
|
||||||
|
val fails = lockPrefs.getInt("fail_count", 0) + 1
|
||||||
|
lockPrefs.edit()
|
||||||
|
.putInt("fail_count", fails)
|
||||||
|
.putLong("last_fail_time", System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
return fails
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetFailures() {
|
||||||
|
lockPrefs.edit().remove("fail_count").remove("last_fail_time").apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lockoutRemainingMs(): Long {
|
||||||
|
val fails = lockPrefs.getInt("fail_count", 0)
|
||||||
|
if (fails < MAX_ATTEMPTS) return 0L
|
||||||
|
val lastFail = lockPrefs.getLong("last_fail_time", 0L)
|
||||||
|
return maxOf(0L, LOCKOUT_MS - (System.currentTimeMillis() - lastFail))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Crypto ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun pbkdf2(input: String, salt: ByteArray): String {
|
||||||
|
val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256)
|
||||||
|
return try {
|
||||||
|
val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||||
|
Base64.encodeToString(hash, Base64.NO_WRAP)
|
||||||
|
} finally {
|
||||||
|
spec.clearPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
|
||||||
|
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
|
||||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
override fun onBackPressed() {
|
|
||||||
// Lock screen cannot be dismissed
|
|
||||||
moveTaskToBack(true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,11 @@ class BmlLoginFlow {
|
|||||||
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
|
val xsrf = xsrfToken() ?: throw Exception("Could not fetch login page")
|
||||||
|
|
||||||
// Step 2: POST credentials
|
// Step 2: POST credentials
|
||||||
val loginBody = """{"username":${quote(username)},"password":${quote(password)},"code":""}"""
|
val loginBody = JSONObject().apply {
|
||||||
.toRequestBody("application/json".toMediaType())
|
put("username", username)
|
||||||
|
put("password", password)
|
||||||
|
put("code", "")
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
val loginResp = client.newCall(
|
val loginResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
|
Request.Builder().url("$BASE_URL/web/login").post(loginBody)
|
||||||
.header("X-XSRF-TOKEN", xsrf)
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
@@ -89,8 +92,10 @@ class BmlLoginFlow {
|
|||||||
|
|
||||||
// Step 4: POST OTP
|
// Step 4: POST OTP
|
||||||
val otp = Totp.generate(otpSeed)
|
val otp = Totp.generate(otpSeed)
|
||||||
val twoFaBody = """{"code":${quote(otp)},"channel":"authenticator"}"""
|
val twoFaBody = JSONObject().apply {
|
||||||
.toRequestBody("application/json".toMediaType())
|
put("code", otp)
|
||||||
|
put("channel", "authenticator")
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
val twoFaResp = client.newCall(
|
val twoFaResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
|
Request.Builder().url("$BASE_URL/web/login/2fa").post(twoFaBody)
|
||||||
.header("X-XSRF-TOKEN", xsrf2)
|
.header("X-XSRF-TOKEN", xsrf2)
|
||||||
@@ -740,6 +745,4 @@ class BmlLoginFlow {
|
|||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun quote(s: String) = "\"${s.replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class SessionExpiredException : Exception("MIB session expired")
|
class SessionExpiredException : Exception("MIB session expired")
|
||||||
|
|
||||||
class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||||
|
|
||||||
private val TAG = "MibLoginFlow"
|
|
||||||
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
|
private val BASE_URL = "https://faisanet.mib.com.mv/faisamobilex_smvc/"
|
||||||
|
|
||||||
/** The active session after a successful login, usable for subsequent WebView requests. */
|
/** The active session after a successful login, usable for subsequent WebView requests. */
|
||||||
@@ -31,7 +32,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
@Volatile private var storedUsername: String? = null
|
@Volatile private var storedUsername: String? = null
|
||||||
@Volatile private var storedPasswordHash: String? = null
|
@Volatile private var storedPasswordHash: String? = null
|
||||||
@Volatile private var storedOtpSeed: String? = null
|
@Volatile private var storedOtpSeed: String? = null
|
||||||
private var inRelogin = false
|
private val inRelogin = AtomicBoolean(false)
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
@@ -61,11 +62,10 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
storedPasswordHash = passwordHash
|
storedPasswordHash = passwordHash
|
||||||
storedOtpSeed = otpSeed
|
storedOtpSeed = otpSeed
|
||||||
val appId = getOrCreateAppId()
|
val appId = getOrCreateAppId()
|
||||||
val key1 = prefs.getString("mib_key1_$username", null)
|
val keys = credentialStore.loadMibKeys()
|
||||||
val key2 = prefs.getString("mib_key2_$username", null)
|
|
||||||
|
|
||||||
return if (key1 != null && key2 != null) {
|
return if (keys != null) {
|
||||||
regularLogin(username, passwordHash, appId, key1, key2)
|
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
||||||
} else {
|
} else {
|
||||||
firstTimeRegistration(username, passwordHash, otpSeed, appId)
|
firstTimeRegistration(username, passwordHash, otpSeed, appId)
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
||||||
val key1 = keyData.getString("key1")
|
val key1 = keyData.getString("key1")
|
||||||
val key2 = keyData.getString("key2")
|
val key2 = keyData.getString("key2")
|
||||||
prefs.edit().putString("mib_key1_$username", key1).putString("mib_key2_$username", key2).apply()
|
credentialStore.saveMibKeys(key1, key2)
|
||||||
|
|
||||||
return regularLogin(username, passwordHash, appId, key1, key2)
|
return regularLogin(username, passwordHash, appId, key1, key2)
|
||||||
}
|
}
|
||||||
@@ -189,7 +189,7 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
val response = try {
|
val response = try {
|
||||||
sendRequest(session, data, sfunc)
|
sendRequest(session, data, sfunc)
|
||||||
} catch (e: SessionExpiredException) {
|
} catch (e: SessionExpiredException) {
|
||||||
if (inRelogin) throw e
|
if (inRelogin.get()) throw e
|
||||||
"" // fall through to recovery below
|
"" // fall through to recovery below
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,22 +198,27 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
(response.trimStart().startsWith("{") &&
|
(response.trimStart().startsWith("{") &&
|
||||||
JSONObject(response).optString("reasonCode") == "505")
|
JSONObject(response).optString("reasonCode") == "505")
|
||||||
|
|
||||||
if (isExpired && !inRelogin) {
|
if (isExpired && inRelogin.compareAndSet(false, true)) {
|
||||||
val u = storedUsername ?: throw SessionExpiredException()
|
try {
|
||||||
val ph = storedPasswordHash ?: throw SessionExpiredException()
|
val u = storedUsername ?: throw SessionExpiredException()
|
||||||
val os = storedOtpSeed ?: throw SessionExpiredException()
|
val ph = storedPasswordHash ?: throw SessionExpiredException()
|
||||||
inRelogin = true
|
val os = storedOtpSeed ?: throw SessionExpiredException()
|
||||||
try { login(u, ph, os) } finally { inRelogin = false }
|
login(u, ph, os)
|
||||||
val newSession = lastSession ?: throw SessionExpiredException()
|
val newSession = lastSession ?: throw SessionExpiredException()
|
||||||
onSessionRefreshed?.invoke(newSession, lastProfiles)
|
onSessionRefreshed?.invoke(newSession, lastProfiles)
|
||||||
// Refresh nonce/xxid in the payload for the retry
|
// Refresh nonce/xxid in the payload for the retry
|
||||||
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
|
data.put("nonce", MibNonce.generate(newSession.nonceGenerator))
|
||||||
data.put("appId", newSession.appId)
|
data.put("appId", newSession.appId)
|
||||||
data.put("sodium", MibNonce.randomSodium())
|
data.put("sodium", MibNonce.randomSodium())
|
||||||
data.put("xxid", newSession.xxid)
|
data.put("xxid", newSession.xxid)
|
||||||
val retryResponse = sendRequest(newSession, data, sfunc)
|
val retryResponse = sendRequest(newSession, data, sfunc)
|
||||||
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
|
if (retryResponse.trimStart().startsWith("{")) return JSONObject(retryResponse)
|
||||||
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
|
return MibCrypto.decrypt(retryResponse, newSession.sessionKey)
|
||||||
|
} finally {
|
||||||
|
inRelogin.set(false)
|
||||||
|
}
|
||||||
|
} else if (isExpired) {
|
||||||
|
throw SessionExpiredException()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
if (response.trimStart().startsWith("{")) return JSONObject(response)
|
||||||
@@ -384,11 +389,11 @@ class MibLoginFlow(private val prefs: android.content.SharedPreferences) {
|
|||||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||||
|
|
||||||
private fun getOrCreateAppId(): String {
|
private fun getOrCreateAppId(): String {
|
||||||
var id = prefs.getString("mib_app_id", null)
|
var id = credentialStore.loadMibAppId()
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||||
prefs.edit().putString("mib_app_id", id).apply()
|
credentialStore.saveMibAppId(id)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.mib
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.util.CacheEncryption
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object TransactionCache {
|
object TransactionCache {
|
||||||
@@ -22,7 +23,7 @@ object TransactionCache {
|
|||||||
put("accountDisplayName", t.accountDisplayName)
|
put("accountDisplayName", t.accountDisplayName)
|
||||||
put("source", t.source)
|
put("source", t.source)
|
||||||
})
|
})
|
||||||
File(context.cacheDir, "tx_$key.json").writeText(arr.toString())
|
File(context.cacheDir, "tx_$key.json").writeText(CacheEncryption.encrypt(arr.toString()))
|
||||||
} catch (_: Exception) {}
|
} catch (_: Exception) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ object TransactionCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context, key: String): List<Transaction> = try {
|
fun load(context: Context, key: String): List<Transaction> = try {
|
||||||
val arr = JSONArray(File(context.cacheDir, "tx_$key.json").readText())
|
val arr = JSONArray(CacheEncryption.decrypt(File(context.cacheDir, "tx_$key.json").readText()))
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
Transaction(
|
Transaction(
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private val autolockHandler = Handler(Looper.getMainLooper())
|
private val autolockHandler = Handler(Looper.getMainLooper())
|
||||||
private var warningDialog: AlertDialog? = null
|
private var warningDialog: AlertDialog? = null
|
||||||
private var countdownTimer: CountDownTimer? = null
|
private var countdownTimer: CountDownTimer? = null
|
||||||
|
private var pauseTime = 0L
|
||||||
|
|
||||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||||
|
|
||||||
@@ -194,11 +195,24 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||||
|
// QrScannerActivity was in the foreground), lock immediately.
|
||||||
|
if (pauseTime > 0L) {
|
||||||
|
val elapsed = System.currentTimeMillis() - pauseTime
|
||||||
|
val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L)
|
||||||
|
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null
|
||||||
|
if (timeout > 0L && elapsed >= timeout && securitySet) {
|
||||||
|
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
resetAutolockTimer()
|
resetAutolockTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
|
pauseTime = System.currentTimeMillis()
|
||||||
autolockHandler.removeCallbacks(autolockRunnable)
|
autolockHandler.removeCallbacks(autolockRunnable)
|
||||||
autolockHandler.removeCallbacks(warningRunnable)
|
autolockHandler.removeCallbacks(warningRunnable)
|
||||||
countdownTimer?.cancel(); countdownTimer = null
|
countdownTimer?.cancel(); countdownTimer = null
|
||||||
@@ -300,8 +314,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val mibJob = mibCreds?.let {
|
val mibJob = mibCreds?.let {
|
||||||
async(Dispatchers.IO) {
|
async(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
val flow = MibLoginFlow(CredentialStore(this@HomeActivity))
|
||||||
val flow = MibLoginFlow(prefs)
|
|
||||||
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
|
val accounts = flow.login(it.username, it.passwordHash, it.otpSeed)
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
app.accounts = accounts
|
app.accounts = accounts
|
||||||
@@ -411,8 +424,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
|
private fun refreshContacts(session: MibSession?, profiles: List<MibProfile>) {
|
||||||
if (session == null || profiles.isEmpty()) return
|
if (session == null || profiles.isEmpty()) return
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
val flow = MibLoginFlow(CredentialStore(this))
|
||||||
val flow = MibLoginFlow(prefs)
|
|
||||||
val contactsClient = MibContactsClient()
|
val contactsClient = MibContactsClient()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -465,8 +477,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val fresh = withContext(Dispatchers.IO) {
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
val sess = app.mibSession ?: return@withContext null
|
val sess = app.mibSession ?: return@withContext null
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
|
val profile = app.mibProfiles.firstOrNull { it.profileId == src.profileId } ?: return@withContext null
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
try { MibLoginFlow(CredentialStore(this@HomeActivity)).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
|
||||||
try { MibLoginFlow(prefs).fetchAllProfiles(sess, listOf(profile), src.loginTag) }
|
|
||||||
catch (_: Exception) { null }
|
catch (_: Exception) { null }
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
// Replace accounts from this profile only, keep everything else
|
// Replace accounts from this profile only, keep everything else
|
||||||
@@ -480,8 +491,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
|
private fun refreshFinancing(session: MibSession?, profiles: List<MibProfile>) {
|
||||||
if (session == null || profiles.isEmpty()) return
|
if (session == null || profiles.isEmpty()) return
|
||||||
val prefs = getSharedPreferences("mib_prefs", MODE_PRIVATE)
|
val flow = MibLoginFlow(CredentialStore(this))
|
||||||
val flow = MibLoginFlow(prefs)
|
|
||||||
val client = MibFinancingClient()
|
val client = MibFinancingClient()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -111,8 +111,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
|
|
||||||
val passwordHash = MibLoginFlow.hashPassword(password)
|
val passwordHash = MibLoginFlow.hashPassword(password)
|
||||||
val prefs = requireContext().getSharedPreferences("mib_prefs", android.content.Context.MODE_PRIVATE)
|
val flow = MibLoginFlow(CredentialStore(requireContext()))
|
||||||
val flow = MibLoginFlow(prefs)
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import androidx.fragment.app.Fragment
|
|||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||||
import java.security.MessageDigest
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
class SecuritySetupFragment : Fragment() {
|
class SecuritySetupFragment : Fragment() {
|
||||||
|
|
||||||
@@ -231,16 +233,25 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
private fun saveCredential(method: String, input: String) {
|
private fun saveCredential(method: String, input: String) {
|
||||||
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
val salt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||||
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
val saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP)
|
||||||
val hash = sha256(saltB64 + input)
|
val hash = pbkdf2(input, salt)
|
||||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
|
||||||
.putString("security_method", method)
|
.putString("security_method", method)
|
||||||
.putString("security_salt", saltB64)
|
// Remove legacy plaintext fields if they exist from an old install
|
||||||
.putString("security_hash", hash)
|
.remove("security_salt")
|
||||||
|
.remove("security_hash")
|
||||||
.apply()
|
.apply()
|
||||||
|
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sha256(input: String) = MessageDigest.getInstance("SHA-256")
|
private fun pbkdf2(input: String, salt: ByteArray): String {
|
||||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
val spec = PBEKeySpec(input.toCharArray(), salt, 100_000, 256)
|
||||||
|
return try {
|
||||||
|
val hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
|
||||||
|
Base64.encodeToString(hash, Base64.NO_WRAP)
|
||||||
|
} finally {
|
||||||
|
spec.clearPassword()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun finishSetup() {
|
private fun finishSetup() {
|
||||||
val cb = activity as? Callback
|
val cb = activity as? Callback
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ object AccountCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY_MIB, arr.toString()).apply()
|
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBml(context: Context, accounts: List<MibAccount>) {
|
fun saveBml(context: Context, accounts: List<MibAccount>) {
|
||||||
@@ -55,13 +55,14 @@ object AccountCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY_BML, arr.toString()).apply()
|
.edit().putString(KEY_BML, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context): List<MibAccount> {
|
fun loadBml(context: Context): List<MibAccount> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_BML, null) ?: return emptyList()
|
.getString(KEY_BML, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
@@ -90,9 +91,10 @@ object AccountCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<MibAccount> {
|
fun load(context: Context): List<MibAccount> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_MIB, null) ?: return emptyList()
|
.getString(KEY_MIB, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package sh.sar.basedbank.util
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared AES-256/GCM encryption for cache SharedPreferences and files.
|
||||||
|
* Uses the same AndroidKeyStore key as CredentialStore so no extra key material is created.
|
||||||
|
*/
|
||||||
|
internal object CacheEncryption {
|
||||||
|
|
||||||
|
private const val KEY_ALIAS = "basedbank_credential_key"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
|
||||||
|
fun encrypt(plaintext: String): String {
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
|
||||||
|
val iv = cipher.iv
|
||||||
|
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
|
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(encoded: String): String {
|
||||||
|
val colon = encoded.indexOf(':')
|
||||||
|
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
|
||||||
|
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
|
||||||
|
return String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
||||||
|
ks.getKey(KEY_ALIAS, null)?.let { return it as SecretKey }
|
||||||
|
|
||||||
|
val spec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
|
||||||
|
.also { it.init(spec) }
|
||||||
|
.generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ object ContactsCache {
|
|||||||
put("profileId", c.profileId)
|
put("profileId", c.profileId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
prefs.putString(KEY_CONTACTS, contactsArr.toString())
|
prefs.putString(KEY_CONTACTS, CacheEncryption.encrypt(contactsArr.toString()))
|
||||||
|
|
||||||
val catArr = JSONArray()
|
val catArr = JSONArray()
|
||||||
for (cat in categories) {
|
for (cat in categories) {
|
||||||
@@ -47,7 +47,7 @@ object ContactsCache {
|
|||||||
put("numBenef", cat.numBenef)
|
put("numBenef", cat.numBenef)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
prefs.putString(KEY_CATEGORIES, catArr.toString())
|
prefs.putString(KEY_CATEGORIES, CacheEncryption.encrypt(catArr.toString()))
|
||||||
prefs.apply()
|
prefs.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +56,10 @@ object ContactsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun loadContacts(context: Context): List<MibBeneficiary> {
|
fun loadContacts(context: Context): List<MibBeneficiary> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_CONTACTS, null) ?: return emptyList()
|
.getString(KEY_CONTACTS, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
@@ -101,13 +102,14 @@ object ContactsCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString("bml_contacts", arr.toString()).apply()
|
.edit().putString("bml_contacts", CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBml(context: Context): List<MibBeneficiary> {
|
fun loadBml(context: Context): List<MibBeneficiary> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString("bml_contacts", null) ?: return emptyList()
|
.getString("bml_contacts", null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
@@ -130,9 +132,10 @@ object ContactsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
|
fun loadCategories(context: Context): List<MibBeneficiaryCategory> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_CATEGORIES, null) ?: return emptyList()
|
.getString(KEY_CATEGORIES, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.security.keystore.KeyGenParameterSpec
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
import android.security.keystore.KeyProperties
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import org.json.JSONObject
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
import javax.crypto.Cipher
|
import javax.crypto.Cipher
|
||||||
import javax.crypto.KeyGenerator
|
import javax.crypto.KeyGenerator
|
||||||
@@ -19,6 +20,8 @@ class CredentialStore(context: Context) {
|
|||||||
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
||||||
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
||||||
|
|
||||||
|
// ── MIB login credentials ─────────────────────────────────────────────────
|
||||||
|
|
||||||
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
|
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username")
|
||||||
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
|
fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username")
|
||||||
|
|
||||||
@@ -42,9 +45,7 @@ class CredentialStore(context: Context) {
|
|||||||
decrypt(encHash, key),
|
decrypt(encHash, key),
|
||||||
decrypt(encSeed, key)
|
decrypt(encSeed, key)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) { null }
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearMibCredentials() {
|
fun clearMibCredentials() {
|
||||||
@@ -52,9 +53,44 @@ class CredentialStore(context: Context) {
|
|||||||
.remove("mib_enc_username")
|
.remove("mib_enc_username")
|
||||||
.remove("mib_enc_password_hash")
|
.remove("mib_enc_password_hash")
|
||||||
.remove("mib_enc_otp_seed")
|
.remove("mib_enc_otp_seed")
|
||||||
|
.remove("mib_enc_key1")
|
||||||
|
.remove("mib_enc_key2")
|
||||||
|
.remove("mib_enc_app_id")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MIB session keys (key1/key2) and app ID ───────────────────────────────
|
||||||
|
|
||||||
|
fun saveMibKeys(key1: String, key2: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("mib_enc_key1", encrypt(key1, key))
|
||||||
|
.putString("mib_enc_key2", encrypt(key2, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMibKeys(): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encKey1 = prefs.getString("mib_enc_key1", null) ?: return null
|
||||||
|
val encKey2 = prefs.getString("mib_enc_key2", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encKey1, key), decrypt(encKey2, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMibAppId(id: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("mib_enc_app_id", encrypt(id, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMibAppId(): String? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("mib_enc_app_id", null) ?: return null
|
||||||
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BML login credentials ─────────────────────────────────────────────────
|
||||||
|
|
||||||
fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
|
fun saveBmlCredentials(username: String, password: String, otpSeed: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
@@ -71,7 +107,7 @@ class CredentialStore(context: Context) {
|
|||||||
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
|
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null
|
||||||
return try {
|
return try {
|
||||||
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
|
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
|
||||||
} catch (e: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearBmlCredentials() {
|
fun clearBmlCredentials() {
|
||||||
@@ -82,6 +118,8 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BML session token ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun saveBmlSession(accessToken: String, deviceId: String) {
|
fun saveBmlSession(accessToken: String, deviceId: String) {
|
||||||
val key = getOrCreateKey()
|
val key = getOrCreateKey()
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
@@ -106,6 +144,39 @@ class CredentialStore(context: Context) {
|
|||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores the PBKDF2-derived hash and its salt, encrypted with the AndroidKeyStore key.
|
||||||
|
* The lock method ("pin"/"pattern") remains in the app's "prefs" SharedPreferences and
|
||||||
|
* is not stored here.
|
||||||
|
*/
|
||||||
|
fun saveSecurityHash(salt: String, hash: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("security_enc_salt", encrypt(salt, key))
|
||||||
|
.putString("security_enc_hash", encrypt(hash, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadSecurityHash(): Pair<String, String>? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encSalt = prefs.getString("security_enc_salt", null) ?: return null
|
||||||
|
val encHash = prefs.getString("security_enc_hash", null) ?: return null
|
||||||
|
return try {
|
||||||
|
Pair(decrypt(encSalt, key), decrypt(encHash, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearSecurityHash() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove("security_enc_salt")
|
||||||
|
.remove("security_enc_hash")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User profile (PII) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class MibUserProfile(
|
data class MibUserProfile(
|
||||||
val fullName: String,
|
val fullName: String,
|
||||||
val username: String,
|
val username: String,
|
||||||
@@ -123,54 +194,89 @@ class CredentialStore(context: Context) {
|
|||||||
val birthdate: String
|
val birthdate: String
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveMibFullName(name: String) = prefs.edit().putString("mib_full_name", name).apply()
|
fun saveMibFullName(name: String) {
|
||||||
fun loadMibFullName(): String? = prefs.getString("mib_full_name", null)
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("mib_enc_full_name", encrypt(name, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun saveBmlFullName(name: String) = prefs.edit().putString("bml_full_name", name).apply()
|
fun loadMibFullName(): String? {
|
||||||
fun loadBmlFullName(): String? = prefs.getString("bml_full_name", null)
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("mib_enc_full_name", null) ?: return null
|
||||||
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveBmlFullName(name: String) {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("bml_enc_full_name", encrypt(name, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadBmlFullName(): String? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("bml_enc_full_name", null) ?: return null
|
||||||
|
return try { decrypt(enc, key) } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
fun saveMibUserProfile(p: MibUserProfile) {
|
fun saveMibUserProfile(p: MibUserProfile) {
|
||||||
prefs.edit().putString("mib_full_name", p.fullName)
|
val json = JSONObject().apply {
|
||||||
.putString("mib_profile_username", p.username)
|
put("fullName", p.fullName)
|
||||||
.putString("mib_profile_email", p.email)
|
put("username", p.username)
|
||||||
.putString("mib_profile_mobile", p.mobile)
|
put("email", p.email)
|
||||||
.putString("mib_profile_enrolled", p.enrolled)
|
put("mobile", p.mobile)
|
||||||
.apply()
|
put("enrolled", p.enrolled)
|
||||||
|
}.toString()
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("mib_enc_profile", encrypt(json, key)).apply()
|
||||||
|
// Keep the name in sync with the fast-path field
|
||||||
|
prefs.edit().putString("mib_enc_full_name", encrypt(p.fullName, key)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadMibUserProfile(): MibUserProfile? {
|
fun loadMibUserProfile(): MibUserProfile? {
|
||||||
val name = prefs.getString("mib_full_name", null) ?: return null
|
val key = getOrCreateKey()
|
||||||
return MibUserProfile(
|
val enc = prefs.getString("mib_enc_profile", null) ?: return null
|
||||||
fullName = name,
|
return try {
|
||||||
username = prefs.getString("mib_profile_username", "") ?: "",
|
val o = JSONObject(decrypt(enc, key))
|
||||||
email = prefs.getString("mib_profile_email", "") ?: "",
|
MibUserProfile(
|
||||||
mobile = prefs.getString("mib_profile_mobile", "") ?: "",
|
fullName = o.optString("fullName"),
|
||||||
enrolled = prefs.getString("mib_profile_enrolled", "") ?: ""
|
username = o.optString("username"),
|
||||||
)
|
email = o.optString("email"),
|
||||||
|
mobile = o.optString("mobile"),
|
||||||
|
enrolled = o.optString("enrolled")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveBmlUserProfile(p: BmlUserProfile) {
|
fun saveBmlUserProfile(p: BmlUserProfile) {
|
||||||
prefs.edit().putString("bml_full_name", p.fullName)
|
val json = JSONObject().apply {
|
||||||
.putString("bml_profile_email", p.email)
|
put("fullName", p.fullName)
|
||||||
.putString("bml_profile_mobile", p.mobile)
|
put("email", p.email)
|
||||||
.putString("bml_profile_customer_id", p.customerId)
|
put("mobile", p.mobile)
|
||||||
.putString("bml_profile_idcard", p.idCard)
|
put("customerId", p.customerId)
|
||||||
.putString("bml_profile_birthdate", p.birthdate)
|
put("idCard", p.idCard)
|
||||||
.apply()
|
put("birthdate", p.birthdate)
|
||||||
|
}.toString()
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("bml_enc_profile", encrypt(json, key)).apply()
|
||||||
|
prefs.edit().putString("bml_enc_full_name", encrypt(p.fullName, key)).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadBmlUserProfile(): BmlUserProfile? {
|
fun loadBmlUserProfile(): BmlUserProfile? {
|
||||||
val name = prefs.getString("bml_full_name", null) ?: return null
|
val key = getOrCreateKey()
|
||||||
return BmlUserProfile(
|
val enc = prefs.getString("bml_enc_profile", null) ?: return null
|
||||||
fullName = name,
|
return try {
|
||||||
email = prefs.getString("bml_profile_email", "") ?: "",
|
val o = JSONObject(decrypt(enc, key))
|
||||||
mobile = prefs.getString("bml_profile_mobile", "") ?: "",
|
BmlUserProfile(
|
||||||
customerId = prefs.getString("bml_profile_customer_id", "") ?: "",
|
fullName = o.optString("fullName"),
|
||||||
idCard = prefs.getString("bml_profile_idcard", "") ?: "",
|
email = o.optString("email"),
|
||||||
birthdate = prefs.getString("bml_profile_birthdate", "") ?: ""
|
mobile = o.optString("mobile"),
|
||||||
)
|
customerId = o.optString("customerId"),
|
||||||
|
idCard = o.optString("idCard"),
|
||||||
|
birthdate = o.optString("birthdate")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Crypto primitives ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun getOrCreateKey(): SecretKey {
|
private fun getOrCreateKey(): SecretKey {
|
||||||
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
val ks = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
|
||||||
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
|
ks.getKey(keyAlias, null)?.let { return it as SecretKey }
|
||||||
@@ -193,16 +299,16 @@ class CredentialStore(context: Context) {
|
|||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
val iv = cipher.iv
|
val iv = cipher.iv
|
||||||
val ct = cipher.doFinal(plaintext.toByteArray())
|
val ct = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
|
||||||
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
|
return Base64.encodeToString(iv, Base64.NO_WRAP) + ":" + Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun decrypt(encoded: String, key: SecretKey): String {
|
private fun decrypt(encoded: String, key: SecretKey): String {
|
||||||
val parts = encoded.split(":")
|
val colon = encoded.indexOf(':')
|
||||||
val iv = Base64.decode(parts[0], Base64.NO_WRAP)
|
val iv = Base64.decode(encoded.substring(0, colon), Base64.NO_WRAP)
|
||||||
val ct = Base64.decode(parts[1], Base64.NO_WRAP)
|
val ct = Base64.decode(encoded.substring(colon + 1), Base64.NO_WRAP)
|
||||||
val cipher = Cipher.getInstance(transformation)
|
val cipher = Cipher.getInstance(transformation)
|
||||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
|
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv))
|
||||||
return String(cipher.doFinal(ct))
|
return String(cipher.doFinal(ct), Charsets.UTF_8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ object FinancingCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY_MIB, arr.toString()).apply()
|
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
@@ -39,9 +39,10 @@ object FinancingCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<MibFinanceDeal> {
|
fun load(context: Context): List<MibFinanceDeal> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_MIB, null) ?: return emptyList()
|
.getString(KEY_MIB, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ object ForeignLimitsCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY, arr.toString()).apply()
|
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
@@ -47,9 +47,10 @@ object ForeignLimitsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<HomeViewModel.BmlLimitsData> {
|
fun load(context: Context): List<HomeViewModel.BmlLimitsData> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY, null) ?: return emptyList()
|
.getString(KEY, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val entry = arr.getJSONObject(i)
|
val entry = arr.getJSONObject(i)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ object RecentsCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY, arr.toString()).apply()
|
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(context: Context, accountNumber: String) {
|
fun remove(context: Context, accountNumber: String) {
|
||||||
@@ -54,7 +54,7 @@ object RecentsCache {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.edit().putString(KEY, arr.toString()).apply()
|
.edit().putString(KEY, CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
@@ -62,9 +62,10 @@ object RecentsCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun load(context: Context): List<RecentPick> {
|
fun load(context: Context): List<RecentPick> {
|
||||||
val json = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
.getString(KEY, null) ?: return emptyList()
|
.getString(KEY, null) ?: return emptyList()
|
||||||
return try {
|
return try {
|
||||||
|
val json = CacheEncryption.decrypt(raw)
|
||||||
val arr = JSONArray(json)
|
val arr = JSONArray(json)
|
||||||
(0 until arr.length()).map { i ->
|
(0 until arr.length()).map { i ->
|
||||||
val o = arr.getJSONObject(i)
|
val o = arr.getJSONObject(i)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
|
<string name="biometric_prompt_subtitle">ފިންގަޕްރިންޓް ނުވަތަ މޫނު ބޭނުން ކޮށްގެން ހުޅުވާ</string>
|
||||||
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
|
<string name="biometric_negative_btn">PIN / ޕެޓަން ބޭނުން ކުރޭ</string>
|
||||||
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
|
<string name="unlock_failed">ދިމައެއް ނުވި — އަލުން ކަނޑޭ</string>
|
||||||
|
<string name="unlock_attempts_remaining">ދިމައެއް ނުވި — %d ފަހަރު ބާކީ</string>
|
||||||
|
<string name="unlock_locked_out">ވަރަށް ގިނަ ފަހަރު ނުކުރިހުރި. %d ސިކުންތު ފަހުން ލޮކް ހިލޭ.</string>
|
||||||
|
|
||||||
<!-- Security setup -->
|
<!-- Security setup -->
|
||||||
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>
|
<string name="security_setup">އެޕް ރައްކާތެރި ކުރޭ</string>
|
||||||
|
|||||||
@@ -36,6 +36,8 @@
|
|||||||
<string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string>
|
<string name="biometric_prompt_subtitle">Use your fingerprint or face to unlock</string>
|
||||||
<string name="biometric_negative_btn">Use PIN / Pattern</string>
|
<string name="biometric_negative_btn">Use PIN / Pattern</string>
|
||||||
<string name="unlock_failed">Incorrect — try again</string>
|
<string name="unlock_failed">Incorrect — try again</string>
|
||||||
|
<string name="unlock_attempts_remaining">Incorrect — %d attempts remaining</string>
|
||||||
|
<string name="unlock_locked_out">Too many attempts. Try again in %d seconds.</string>
|
||||||
|
|
||||||
<!-- Security setup -->
|
<!-- Security setup -->
|
||||||
<string name="security_setup">Secure Your App</string>
|
<string name="security_setup">Secure Your App</string>
|
||||||
|
|||||||
@@ -6,8 +6,14 @@
|
|||||||
See https://developer.android.com/about/versions/12/backup-restore
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
-->
|
-->
|
||||||
<full-backup-content>
|
<full-backup-content>
|
||||||
<!--
|
<exclude domain="sharedpref" path="credential_store.xml"/>
|
||||||
<include domain="sharedpref" path="."/>
|
<exclude domain="sharedpref" path="mib_prefs.xml"/>
|
||||||
<exclude domain="sharedpref" path="device.xml"/>
|
<exclude domain="sharedpref" path="prefs.xml"/>
|
||||||
-->
|
<exclude domain="sharedpref" path="account_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="contacts_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="financing_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||||
|
<exclude domain="cache" path="."/>
|
||||||
</full-backup-content>
|
</full-backup-content>
|
||||||
@@ -5,15 +5,27 @@
|
|||||||
-->
|
-->
|
||||||
<data-extraction-rules>
|
<data-extraction-rules>
|
||||||
<cloud-backup>
|
<cloud-backup>
|
||||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
<exclude domain="sharedpref" path="credential_store.xml"/>
|
||||||
<include .../>
|
<exclude domain="sharedpref" path="mib_prefs.xml"/>
|
||||||
<exclude .../>
|
<exclude domain="sharedpref" path="prefs.xml"/>
|
||||||
-->
|
<exclude domain="sharedpref" path="account_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="contacts_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="financing_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||||
|
<exclude domain="cache" path="."/>
|
||||||
</cloud-backup>
|
</cloud-backup>
|
||||||
<!--
|
|
||||||
<device-transfer>
|
<device-transfer>
|
||||||
<include .../>
|
<exclude domain="sharedpref" path="credential_store.xml"/>
|
||||||
<exclude .../>
|
<exclude domain="sharedpref" path="mib_prefs.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="prefs.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="account_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="contacts_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="financing_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="recents_cache.xml"/>
|
||||||
|
<exclude domain="sharedpref" path="lock_attempts.xml"/>
|
||||||
|
<exclude domain="cache" path="."/>
|
||||||
</device-transfer>
|
</device-transfer>
|
||||||
-->
|
|
||||||
</data-extraction-rules>
|
</data-extraction-rules>
|
||||||
@@ -1,6 +1,2 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<network-security-config>
|
<network-security-config/>
|
||||||
<domain-config cleartextTrafficPermitted="true">
|
|
||||||
<domain includeSubdomains="true">faisanet.mib.com.mv</domain>
|
|
||||||
</domain-config>
|
|
||||||
</network-security-config>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user