Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
98990544fc
|
|||
|
798e9da9ca
|
|||
|
014c002ebe
|
|||
|
6f8b7130fe
|
|||
|
05430f043a
|
|||
|
80bbacc130
|
|||
|
570e6b750b
|
|||
|
21fbd8b12c
|
|||
|
d0f46e2118
|
|||
|
71002ed70c
|
|||
|
fbc34d6435
|
|||
|
4b1c2419ec
|
@@ -18,3 +18,4 @@ docs/bmlapi/tmp
|
|||||||
docs/fahipayapi/tmp
|
docs/fahipayapi/tmp
|
||||||
tmp
|
tmp
|
||||||
app/key.jks
|
app/key.jks
|
||||||
|
.kotlin/*
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 17
|
versionCode = 19
|
||||||
versionName = "1.0.18"
|
versionName = "1.0.19"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@
|
|||||||
<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-permission android:name="android.permission.NFC" />
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
@@ -69,6 +73,11 @@
|
|||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/Theme.BasedBank" />
|
android:theme="@style/Theme.BasedBank" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.NotificationPollingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".nfc.BmlHostCardEmulatorService"
|
android:name=".nfc.BmlHostCardEmulatorService"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
|
|
||||||
|
data class BmlCardActionResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String
|
||||||
|
)
|
||||||
|
|
||||||
|
class BmlCardClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freezes or unfreezes a BML card.
|
||||||
|
* @param cardId BML card UUID (BankAccount.internalId)
|
||||||
|
* @param action "freeze" or "unfreeze"
|
||||||
|
*/
|
||||||
|
fun setCardFreezeState(session: BmlSession, cardId: String, action: String): BmlCardActionResult {
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("card", cardId)
|
||||||
|
put("action", action)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BML_BASE_URL/api/mobile/services/card/freeze")
|
||||||
|
.post(body)
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val resp = client.newCall(request).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val responseBody = resp.body?.string()
|
||||||
|
resp.close()
|
||||||
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
|
if (code in 500..599) throw BankServerException("BML")
|
||||||
|
return try {
|
||||||
|
val json = JSONObject(responseBody ?: "")
|
||||||
|
val ok = json.optBoolean("success") && json.optInt("code") == 0
|
||||||
|
BmlCardActionResult(
|
||||||
|
success = ok,
|
||||||
|
message = json.optString("payload").ifBlank { json.optString("message") }
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
BmlCardActionResult(success = false, message = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
private val SKIP_TYPES = setOf("Switch Profile")
|
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
|
||||||
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||||
|
|
||||||
class MibActivityHistoryClient {
|
class MibActivityHistoryClient {
|
||||||
@@ -117,7 +117,7 @@ class MibActivityHistoryClient {
|
|||||||
session: MibSession,
|
session: MibSession,
|
||||||
loginId: String,
|
loginId: String,
|
||||||
minCount: Int = 5,
|
minCount: Int = 5,
|
||||||
pageSize: Int = 30
|
pageSize: Int = 100
|
||||||
): FetchResult {
|
): FetchResult {
|
||||||
val accumulated = mutableListOf<AppNotification>()
|
val accumulated = mutableListOf<AppNotification>()
|
||||||
var start = 1
|
var start = 1
|
||||||
|
|||||||
@@ -7,10 +7,18 @@ import okhttp3.Request
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
data class MibCardActionResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val currentStatusCode: String
|
||||||
|
)
|
||||||
|
|
||||||
class MibCardsClient {
|
class MibCardsClient {
|
||||||
|
|
||||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||||
|
|
||||||
|
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
|
|
||||||
private val client = OkHttpClient.Builder()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.readTimeout(20, TimeUnit.SECONDS)
|
.readTimeout(20, TimeUnit.SECONDS)
|
||||||
@@ -20,7 +28,7 @@ class MibCardsClient {
|
|||||||
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||||
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
|
|
||||||
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
|
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
|
||||||
val body = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
.add("name", "")
|
.add("name", "")
|
||||||
.add("start", "1")
|
.add("start", "1")
|
||||||
@@ -32,7 +40,7 @@ class MibCardsClient {
|
|||||||
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
||||||
.post(body)
|
.post(body)
|
||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
.header("User-Agent", USER_AGENT)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
.header("Origin", BASE_WV_URL)
|
.header("Origin", BASE_WV_URL)
|
||||||
@@ -55,9 +63,42 @@ class MibCardsClient {
|
|||||||
customerId = item.optString("customerId"),
|
customerId = item.optString("customerId"),
|
||||||
phoneNumber = item.optString("phoneNumber"),
|
phoneNumber = item.optString("phoneNumber"),
|
||||||
cardHolderName = item.optString("cardHolderName"),
|
cardHolderName = item.optString("cardHolderName"),
|
||||||
loginTag = loginTag
|
loginTag = loginTag,
|
||||||
|
profileId = profileId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Freezes a MIB card. action = "freeze" or "unfreeze". */
|
||||||
|
fun setCardFreezeState(session: MibSession, cardId: String, action: String, comments: String): MibCardActionResult {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("cardId", cardId)
|
||||||
|
.add("comments", comments)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$BASE_WV_URL/ajaxDebitCard/$action")
|
||||||
|
.post(body)
|
||||||
|
.header("Cookie", cookieHeader(session))
|
||||||
|
.header("User-Agent", USER_AGENT)
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Origin", BASE_WV_URL)
|
||||||
|
.header("Referer", "$BASE_WV_URL//debitCards/manage?cardId=$cardId&dashurl=1")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val bodyStr = response.body?.string()
|
||||||
|
?: return MibCardActionResult(false, "", "")
|
||||||
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) {
|
||||||
|
return MibCardActionResult(false, "", "")
|
||||||
|
}
|
||||||
|
MibCardActionResult(
|
||||||
|
success = json.optBoolean("success"),
|
||||||
|
message = json.optString("reasonText"),
|
||||||
|
currentStatusCode = json.optString("currentStatusCode")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,8 @@ data class MibCard(
|
|||||||
val customerId: String,
|
val customerId: String,
|
||||||
val phoneNumber: String,
|
val phoneNumber: String,
|
||||||
val cardHolderName: String,
|
val cardHolderName: String,
|
||||||
val loginTag: String
|
val loginTag: String,
|
||||||
|
val profileId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibFinanceDeal(
|
data class MibFinanceDeal(
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package sh.sar.basedbank.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
import sh.sar.basedbank.util.NotificationsCache
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class NotificationPollingService : Service() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val app get() = application as BasedBankApp
|
||||||
|
private val notifIdCounter = AtomicInteger(2000)
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannels()
|
||||||
|
startForeground(SERVICE_NOTIF_ID, buildServiceNotification())
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
runCatching { poll() }
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun poll() {
|
||||||
|
pollBml()
|
||||||
|
pollMib()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pollBml() {
|
||||||
|
val sessions = app.bmlSessions.toMap()
|
||||||
|
if (sessions.isEmpty()) return
|
||||||
|
val client = BmlNotificationsClient()
|
||||||
|
sessions.forEach { (loginId, session) ->
|
||||||
|
val result = try { client.fetchNotifications(session, loginId, page = 1) }
|
||||||
|
catch (_: Exception) { return@forEach }
|
||||||
|
if (result.items.isEmpty()) return@forEach
|
||||||
|
|
||||||
|
val cached = NotificationsCache.loadBml(this@NotificationPollingService, loginId)
|
||||||
|
if (cached.isEmpty()) {
|
||||||
|
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val cachedIds = cached.map { it.id }.toSet()
|
||||||
|
val newItems = result.items.filter { it.id !in cachedIds }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||||
|
val channelId = ensureLoginChannel("BML", loginId)
|
||||||
|
newItems.forEach { postBankNotification(it, channelId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pollMib() {
|
||||||
|
val sessions = app.mibSessions.toMap()
|
||||||
|
if (sessions.isEmpty()) return
|
||||||
|
val client = MibActivityHistoryClient()
|
||||||
|
sessions.forEach { (loginId, session) ->
|
||||||
|
val result = try { client.fetchActivity(session, loginId, 1, 100) }
|
||||||
|
catch (_: Exception) { return@forEach }
|
||||||
|
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(this@NotificationPollingService)
|
||||||
|
val cached = NotificationsCache.loadMib(this@NotificationPollingService, loginId, readIds)
|
||||||
|
if (cached.isEmpty()) {
|
||||||
|
NotificationsCache.saveMib(this@NotificationPollingService, loginId, result.items)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val cachedIds = cached.map { it.id }.toSet()
|
||||||
|
val newItems = result.items.filter { it.id !in cachedIds }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
val all = (cached + newItems).sortedByDescending { it.timestampMs }
|
||||||
|
NotificationsCache.saveMib(this@NotificationPollingService, loginId, all)
|
||||||
|
val channelId = ensureLoginChannel("MIB", loginId)
|
||||||
|
newItems.forEach { postBankNotification(it, channelId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLoginChannel(bank: String, loginId: String): String {
|
||||||
|
val channelId = "bank_${bank.lowercase()}_$loginId"
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (nm.getNotificationChannel(channelId) == null) {
|
||||||
|
val profileName = when (bank) {
|
||||||
|
"BML" -> app.bmlProfilesMap[loginId]?.firstOrNull()?.name
|
||||||
|
"MIB" -> app.mibProfilesMap[loginId]?.firstOrNull()?.name
|
||||||
|
else -> null
|
||||||
|
} ?: loginId
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(channelId, "$bank · $profileName", NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return channelId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postBankNotification(notif: AppNotification, channelId: String) {
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val n = Notification.Builder(this, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_bell)
|
||||||
|
.setContentTitle(notif.title)
|
||||||
|
.setContentText(notif.message)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
nm.notify(notifIdCounter.getAndIncrement(), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildServiceNotification(): Notification {
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return Notification.Builder(this, CHANNEL_SERVICE)
|
||||||
|
.setSmallIcon(R.drawable.ic_bell)
|
||||||
|
.setContentTitle(getString(R.string.notif_service_title))
|
||||||
|
.setContentText(getString(R.string.notif_service_desc))
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannels() {
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_SERVICE,
|
||||||
|
getString(R.string.notif_channel_service),
|
||||||
|
NotificationManager.IMPORTANCE_MIN
|
||||||
|
).apply { setShowBadge(false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val POLL_INTERVAL_MS = 30_000L
|
||||||
|
private const val SERVICE_NOTIF_ID = 1001
|
||||||
|
const val CHANNEL_SERVICE = "notif_polling_service"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -151,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
|
|
||||||
(activity as? HomeActivity)?.triggerRefresh()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachMediator(pages: List<TabDef>) {
|
private fun attachMediator(pages: List<TabDef>) {
|
||||||
|
|||||||
@@ -116,12 +116,12 @@ class DashboardFragment : Fragment() {
|
|||||||
val credStore = CredentialStore(requireContext())
|
val credStore = CredentialStore(requireContext())
|
||||||
val hidden = credStore.getHiddenDashboardCardNumbers()
|
val hidden = credStore.getHiddenDashboardCardNumbers()
|
||||||
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
||||||
.filter { !hidden.contains(it.maskedCardNumber) }
|
.filter { CardsFragment.isMibCardActive(it.cardStatus) && !hidden.contains(it.maskedCardNumber) }
|
||||||
.map { CardItem.Mib(it) }
|
.map { CardItem.Mib(it) }
|
||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||||
.map { CardItem.Bml(it) }
|
.map { CardItem.Bml(it) }
|
||||||
val all = mibItems + bmlItems
|
val all = bmlItems + mibItems
|
||||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
val ordered = if (defaultNum != null) {
|
val ordered = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
|||||||
@@ -1130,14 +1130,14 @@ fun applyNavLabelVisibility() {
|
|||||||
val fresh = withContext(Dispatchers.IO) {
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
||||||
try {
|
try {
|
||||||
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
|
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag, src.profileName, src.profileId)
|
||||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||||
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
|
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag || it.profileId != src.profileId }
|
||||||
app.bmlAccounts = otherBml + accounts
|
app.bmlAccounts = otherBml + accounts
|
||||||
accounts
|
accounts
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
|
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag || it.profileId != src.profileId }
|
||||||
viewModel.accounts.postValue(otherAccounts + fresh)
|
viewModel.accounts.postValue(otherAccounts + fresh)
|
||||||
} else {
|
} else {
|
||||||
val loginId = src.loginTag.removePrefix("mib_")
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
@@ -1220,7 +1220,7 @@ fun applyNavLabelVisibility() {
|
|||||||
for (profile in profiles) {
|
for (profile in profiles) {
|
||||||
try {
|
try {
|
||||||
flow.switchProfile(session, profile)
|
flow.switchProfile(session, profile)
|
||||||
for (card in client.fetchCards(session, "mib_$loginId")) {
|
for (card in client.fetchCards(session, "mib_$loginId", profile.profileId)) {
|
||||||
if (seen.add(card.cardId)) result += card
|
if (seen.add(card.cardId)) result += card
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import android.view.ViewGroup
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import androidx.viewpager2.widget.ViewPager2
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
@@ -62,6 +64,23 @@ private fun toGroupedList(notifications: List<AppNotification>): List<NotifListI
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class NotifDiff(
|
||||||
|
private val old: List<NotifListItem>,
|
||||||
|
private val new: List<NotifListItem>
|
||||||
|
) : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize() = old.size
|
||||||
|
override fun getNewListSize() = new.size
|
||||||
|
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
|
||||||
|
val o = old[oldPos]; val n = new[newPos]
|
||||||
|
return when {
|
||||||
|
o is NotifListItem.Header && n is NotifListItem.Header -> o.label == n.label
|
||||||
|
o is NotifListItem.Entry && n is NotifListItem.Entry -> o.n.id == n.n.id
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun areContentsTheSame(oldPos: Int, newPos: Int) = old[oldPos] == new[newPos]
|
||||||
|
}
|
||||||
|
|
||||||
class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
|
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
|
||||||
@@ -148,11 +167,16 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setSpinner(show: Boolean) {
|
||||||
|
tabAdapters.forEach { it?.showLoadingSpinner = show }
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshFromNetwork() {
|
private fun refreshFromNetwork() {
|
||||||
val bmlSessions = app.bmlSessions.toMap()
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
val mibSessions = app.mibSessions.toMap()
|
val mibSessions = app.mibSessions.toMap()
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
|
setSpinner(true)
|
||||||
val bmlClient = BmlNotificationsClient()
|
val bmlClient = BmlNotificationsClient()
|
||||||
bmlSessions.forEach { (loginId, session) ->
|
bmlSessions.forEach { (loginId, session) ->
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
@@ -172,22 +196,32 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
val mibClient = MibActivityHistoryClient()
|
val mibClient = MibActivityHistoryClient()
|
||||||
mibSessions.forEach { (loginId, session) ->
|
mibSessions.forEach { (loginId, session) ->
|
||||||
|
val cachedIds = allNotifications
|
||||||
|
.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
.map { it.id }.toSet()
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
mibClient.fetchUntilEnough(session, loginId)
|
mibClient.fetchActivity(session, loginId, 1, 100)
|
||||||
}
|
}
|
||||||
if (result.items.isNotEmpty() && isAdded) {
|
if (isAdded) {
|
||||||
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
|
val hasOverlap = cachedIds.isNotEmpty() && result.items.any { it.id in cachedIds }
|
||||||
allNotifications.removeAll { it.bank == "MIB" && it.loginId == loginId }
|
val newItems = result.items
|
||||||
allNotifications.addAll(resolved)
|
.filter { it.id !in cachedIds }
|
||||||
allNotifications.sortByDescending { it.timestampMs }
|
.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
allNotifications.addAll(newItems)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
mibNextStart[loginId] = result.nextStart
|
mibNextStart[loginId] = result.nextStart
|
||||||
mibDone[loginId] = result.nextStart > result.totalCount
|
mibDone[loginId] = hasOverlap || result.nextStart > result.totalCount
|
||||||
NotificationsCache.saveMib(requireContext(), loginId, result.items)
|
|
||||||
refreshAdapters()
|
|
||||||
broadcastUnread()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAdded) setSpinner(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +234,7 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
|||||||
if (!anyLeft) return
|
if (!anyLeft) return
|
||||||
|
|
||||||
isLoadingMore = true
|
isLoadingMore = true
|
||||||
|
setSpinner(true)
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val bmlClient = BmlNotificationsClient()
|
val bmlClient = BmlNotificationsClient()
|
||||||
bmlSessions.forEach { (loginId, session) ->
|
bmlSessions.forEach { (loginId, session) ->
|
||||||
@@ -221,24 +256,36 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val mibClient = MibActivityHistoryClient()
|
val mibClient = MibActivityHistoryClient()
|
||||||
mibSessions.forEach { (loginId, session) ->
|
mibSessions.forEach { (loginId, session) ->
|
||||||
if (mibDone[loginId] == true) return@forEach
|
if (mibDone[loginId] == true) return@forEach
|
||||||
val start = mibNextStart[loginId] ?: 1
|
while (mibDone[loginId] != true && isAdded) {
|
||||||
val result = withContext(Dispatchers.IO) {
|
val start = mibNextStart[loginId] ?: 101
|
||||||
mibClient.fetchActivity(session, loginId, start, start + 29)
|
val cachedIds = allNotifications
|
||||||
}
|
.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
if (result.items.isNotEmpty() && isAdded) {
|
.map { it.id }.toSet()
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
mibClient.fetchActivity(session, loginId, start, start + 99)
|
||||||
|
}
|
||||||
|
if (result.rawCount == 0) break
|
||||||
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
|
val newItems = result.items
|
||||||
allNotifications.addAll(resolved.filter { n -> allNotifications.none { it.id == n.id } })
|
.filter { it.id !in cachedIds }
|
||||||
allNotifications.sortByDescending { it.timestampMs }
|
.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
allNotifications.addAll(newItems)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||||
|
}
|
||||||
mibNextStart[loginId] = result.nextStart
|
mibNextStart[loginId] = result.nextStart
|
||||||
mibDone[loginId] = result.nextStart > result.totalCount
|
mibDone[loginId] = result.nextStart > result.totalCount
|
||||||
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
if (newItems.isNotEmpty()) break
|
||||||
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingMore = false
|
isLoadingMore = false
|
||||||
if (isAdded) refreshAdapters()
|
if (isAdded) {
|
||||||
|
setSpinner(false)
|
||||||
|
refreshAdapters()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,16 +389,35 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private val displayItems = mutableListOf<NotifListItem>()
|
private val displayItems = mutableListOf<NotifListItem>()
|
||||||
|
|
||||||
|
var showLoadingSpinner: Boolean = false
|
||||||
|
set(value) {
|
||||||
|
if (field == value) return
|
||||||
|
field = value
|
||||||
|
if (displayItems.isEmpty()) {
|
||||||
|
notifyItemChanged(0)
|
||||||
|
} else if (value) {
|
||||||
|
notifyItemInserted(displayItems.size)
|
||||||
|
} else {
|
||||||
|
notifyItemRemoved(displayItems.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun update(filtered: List<AppNotification>) {
|
fun update(filtered: List<AppNotification>) {
|
||||||
|
val newItems = toGroupedList(filtered)
|
||||||
|
val diff = DiffUtil.calculateDiff(NotifDiff(displayItems.toList(), newItems))
|
||||||
displayItems.clear()
|
displayItems.clear()
|
||||||
displayItems.addAll(toGroupedList(filtered))
|
displayItems.addAll(newItems)
|
||||||
notifyDataSetChanged()
|
diff.dispatchUpdatesTo(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemCount() = if (displayItems.isEmpty()) 1 else displayItems.size
|
override fun getItemCount(): Int {
|
||||||
|
if (displayItems.isEmpty()) return 1
|
||||||
|
return displayItems.size + if (showLoadingSpinner) 1 else 0
|
||||||
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int): Int {
|
override fun getItemViewType(position: Int): Int {
|
||||||
if (displayItems.isEmpty()) return 2 // empty
|
if (displayItems.isEmpty()) return if (showLoadingSpinner) 3 else 2
|
||||||
|
if (showLoadingSpinner && position == displayItems.size) return 3
|
||||||
return when (displayItems[position]) {
|
return when (displayItems[position]) {
|
||||||
is NotifListItem.Header -> 0
|
is NotifListItem.Header -> 0
|
||||||
is NotifListItem.Entry -> 1
|
is NotifListItem.Entry -> 1
|
||||||
@@ -362,6 +428,7 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
|||||||
when (viewType) {
|
when (viewType) {
|
||||||
0 -> HeaderVH(buildHeaderView(parent.context))
|
0 -> HeaderVH(buildHeaderView(parent.context))
|
||||||
1 -> ItemVH(buildRowView(parent.context))
|
1 -> ItemVH(buildRowView(parent.context))
|
||||||
|
3 -> SpinnerVH(buildSpinnerView(parent.context))
|
||||||
else -> EmptyVH(buildEmptyView(parent.context))
|
else -> EmptyVH(buildEmptyView(parent.context))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +489,28 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Loading spinner ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class SpinnerVH(v: View) : RecyclerView.ViewHolder(v)
|
||||||
|
|
||||||
|
private fun buildSpinnerView(ctx: android.content.Context): View {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val pad = (16 * dp).toInt()
|
||||||
|
val size = (28 * dp).toInt()
|
||||||
|
return LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
addView(ProgressBar(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Notification row ──────────────────────────────────────────────────────
|
// ── Notification row ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
|
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
|
||||||
|
|||||||
@@ -37,12 +37,19 @@ 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.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlCardClient
|
||||||
import sh.sar.basedbank.api.bml.BmlTapToPayClient
|
import sh.sar.basedbank.api.bml.BmlTapToPayClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibCardsClient
|
||||||
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
|
import android.text.InputType
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.textfield.TextInputEditText
|
||||||
|
import com.google.android.material.textfield.TextInputLayout
|
||||||
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
|
||||||
@@ -63,6 +70,8 @@ class CardsFragment : Fragment() {
|
|||||||
private var cardWidth: Int = 0
|
private var cardWidth: Int = 0
|
||||||
private var pendingQrCardNumber: String? = null
|
private var pendingQrCardNumber: String? = null
|
||||||
private var isManageMode: Boolean = false
|
private var isManageMode: Boolean = false
|
||||||
|
private var managedCardKey: String? = null
|
||||||
|
private var freezeInFlight: Boolean = false
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
@@ -155,8 +164,14 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
|
viewModel.mibCards.observe(viewLifecycleOwner) {
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
|
rebuildCards()
|
||||||
|
rebindManagedCardIfNeeded()
|
||||||
|
}
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||||
|
rebuildCards()
|
||||||
|
rebindManagedCardIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
val cached = CardsCache.load(requireContext())
|
val cached = CardsCache.load(requireContext())
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
@@ -247,20 +262,161 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
binding.btnChangePin.setOnClickListener(wip)
|
binding.btnChangePin.setOnClickListener(wip)
|
||||||
binding.btnFreeze.setOnClickListener(wip)
|
binding.btnFreeze.setOnClickListener {
|
||||||
|
when (val item = cards.getOrNull(currentCardPosition)) {
|
||||||
|
is CardItem.Bml -> confirmBmlFreezeToggle(item)
|
||||||
|
is CardItem.Mib -> confirmMibFreezeToggle(item)
|
||||||
|
null -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
binding.btnBlock.setOnClickListener(wip)
|
binding.btnBlock.setOnClickListener(wip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
|
||||||
|
if (freezeInFlight) return
|
||||||
|
val frozen = isBmlFrozen(item.account.statusDesc)
|
||||||
|
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
|
||||||
|
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
|
||||||
|
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle(titleRes)
|
||||||
|
.setMessage(messageRes)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(confirmRes) { _, _ -> performBmlFreezeToggle(item, freeze = !frozen) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmMibFreezeToggle(item: CardItem.Mib) {
|
||||||
|
if (freezeInFlight) return
|
||||||
|
val frozen = isMibCardFrozen(item.card.cardStatus)
|
||||||
|
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
|
||||||
|
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
|
||||||
|
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
|
||||||
|
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
val inputLayout = TextInputLayout(ctx).apply {
|
||||||
|
hint = getString(R.string.card_freeze_comments_hint)
|
||||||
|
val pad = (16 * dp).toInt()
|
||||||
|
setPadding(pad, pad / 2, pad, 0)
|
||||||
|
}
|
||||||
|
val input = TextInputEditText(ctx).apply {
|
||||||
|
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||||
|
maxLines = 3
|
||||||
|
}
|
||||||
|
inputLayout.addView(input)
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(titleRes)
|
||||||
|
.setMessage(messageRes)
|
||||||
|
.setView(inputLayout)
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.setPositiveButton(confirmRes) { _, _ ->
|
||||||
|
val comments = input.text?.toString()?.trim().orEmpty()
|
||||||
|
performMibFreezeToggle(item, freeze = !frozen, comments = comments)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performMibFreezeToggle(item: CardItem.Mib, freeze: Boolean, comments: String) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val action = if (freeze) "freeze" else "unfreeze"
|
||||||
|
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
|
||||||
|
val loginId = item.card.loginTag.removePrefix("mib_")
|
||||||
|
val session = app.mibSessions[loginId]
|
||||||
|
if (session == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val ownerProfile = profiles.firstOrNull { it.profileId == item.card.profileId }
|
||||||
|
?: profiles.firstOrNull { it.customerId == item.card.customerId }
|
||||||
|
freezeInFlight = true
|
||||||
|
binding.btnFreeze.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
app.mibMutex.withLock {
|
||||||
|
if (ownerProfile != null) {
|
||||||
|
app.mibFlowFor(loginId).switchProfile(session, ownerProfile)
|
||||||
|
}
|
||||||
|
MibCardsClient().setCardFreezeState(session, item.card.cardId, action, comments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freezeInFlight = false
|
||||||
|
if (!isAdded || _binding == null) {
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
binding.btnFreeze.isEnabled = true
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
val response = result.getOrNull()
|
||||||
|
if (response?.success == true) {
|
||||||
|
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
|
||||||
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
} else {
|
||||||
|
val msg = response?.message?.takeIf { it.isNotBlank() }
|
||||||
|
?: result.exceptionOrNull()?.message
|
||||||
|
?: getString(R.string.card_freeze_failed)
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performBmlFreezeToggle(item: CardItem.Bml, freeze: Boolean) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val action = if (freeze) "freeze" else "unfreeze"
|
||||||
|
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
|
||||||
|
freezeInFlight = true
|
||||||
|
binding.btnFreeze.isEnabled = false
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val session = app.bmlSessionFor(item.account)
|
||||||
|
if (session == null) {
|
||||||
|
freezeInFlight = false
|
||||||
|
if (_binding != null) binding.btnFreeze.isEnabled = true
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlCardClient().setCardFreezeState(session, item.account.internalId, action) }
|
||||||
|
}
|
||||||
|
freezeInFlight = false
|
||||||
|
if (!isAdded || _binding == null) {
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
binding.btnFreeze.isEnabled = true
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
val response = result.getOrNull()
|
||||||
|
if (response?.success == true) {
|
||||||
|
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
|
||||||
|
(activity as? HomeActivity)?.refreshBalances(item.account)
|
||||||
|
} else {
|
||||||
|
val msg = response?.message?.takeIf { it.isNotBlank() }
|
||||||
|
?: result.exceptionOrNull()?.message
|
||||||
|
?: getString(R.string.card_freeze_failed)
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun setManageMode(enabled: Boolean) {
|
private fun setManageMode(enabled: Boolean) {
|
||||||
isManageMode = enabled
|
isManageMode = enabled
|
||||||
|
if (!enabled) managedCardKey = null
|
||||||
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
||||||
if (enabled) enterManageMode() else exitManageMode()
|
if (enabled) enterManageMode() else exitManageMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enterManageMode() {
|
private fun cardItemKey(item: CardItem): String = when (item) {
|
||||||
val item = cards.getOrNull(currentCardPosition) ?: return
|
is CardItem.Bml -> "bml:${item.account.accountNumber}"
|
||||||
|
is CardItem.Mib -> "mib:${item.card.cardId}"
|
||||||
|
}
|
||||||
|
|
||||||
// Bind card data
|
private fun bindManageCardData(item: CardItem) {
|
||||||
val cv = binding.manageCardView
|
val cv = binding.manageCardView
|
||||||
when (item) {
|
when (item) {
|
||||||
is CardItem.Mib -> {
|
is CardItem.Mib -> {
|
||||||
@@ -270,7 +426,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
|
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
|
||||||
else cv.ivCardImage.setImageDrawable(null)
|
else cv.ivCardImage.setImageDrawable(null)
|
||||||
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||||
cv.root.alpha = 1f
|
cv.root.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
|
||||||
}
|
}
|
||||||
is CardItem.Bml -> {
|
is CardItem.Bml -> {
|
||||||
cv.tvCardOwner.text = item.account.accountBriefName
|
cv.tvCardOwner.text = item.account.accountBriefName
|
||||||
@@ -281,6 +437,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
cv.root.alpha = if (isActive) 1f else 0.45f
|
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val isFrozen = when (item) {
|
||||||
|
is CardItem.Bml -> isBmlFrozen(item.account.statusDesc)
|
||||||
|
is CardItem.Mib -> isMibCardFrozen(item.card.cardStatus)
|
||||||
|
}
|
||||||
|
binding.btnFreeze.setText(if (isFrozen) R.string.card_action_unfreeze else R.string.card_action_freeze)
|
||||||
|
// MIB doesn't allow change PIN / block while a card is frozen; BML still does.
|
||||||
|
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
|
||||||
|
binding.btnChangePin.isEnabled = !mibFrozen
|
||||||
|
binding.btnBlock.isEnabled = !mibFrozen
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebindManagedCardIfNeeded() {
|
||||||
|
if (!isManageMode) return
|
||||||
|
val key = managedCardKey ?: return
|
||||||
|
val newPos = cards.indexOfFirst { cardItemKey(it) == key }
|
||||||
|
if (newPos < 0) return
|
||||||
|
if (newPos != currentCardPosition) {
|
||||||
|
currentCardPosition = newPos
|
||||||
|
binding.rvCards.scrollToPosition(newPos)
|
||||||
|
}
|
||||||
|
bindManageCardData(cards[newPos])
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBmlFrozen(statusDesc: String): Boolean =
|
||||||
|
statusDesc.equals("Block Plastic", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun enterManageMode() {
|
||||||
|
val item = cards.getOrNull(currentCardPosition) ?: return
|
||||||
|
managedCardKey = cardItemKey(item)
|
||||||
|
|
||||||
|
bindManageCardData(item)
|
||||||
|
|
||||||
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
|
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
|
||||||
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||||
@@ -333,6 +520,16 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
binding.switchDefaultCard.isEnabled = !isInactiveBml
|
||||||
|
if (isInactiveBml) {
|
||||||
|
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
|
||||||
|
binding.switchHideFromDashboard.isChecked = true
|
||||||
|
binding.switchHideFromDashboard.isEnabled = false
|
||||||
|
} else {
|
||||||
|
binding.switchHideFromDashboard.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
// After layout pass, compute offsets, save carousel snapshot, and animate
|
// After layout pass, compute offsets, save carousel snapshot, and animate
|
||||||
binding.contentLayout.doOnNextLayout {
|
binding.contentLayout.doOnNextLayout {
|
||||||
val mgr = binding.manageCardView.root
|
val mgr = binding.manageCardView.root
|
||||||
@@ -704,7 +901,11 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||||
.map { CardItem.Bml(it) }
|
.map { CardItem.Bml(it) }
|
||||||
val all: List<CardItem> = mibItems + bmlItems
|
val bmlActive = bmlItems.filter { it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
|
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||||
|
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||||
|
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
||||||
|
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
|
||||||
// Move default BML card to front
|
// Move default BML card to front
|
||||||
cards = if (defaultNum != null) {
|
cards = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
@@ -809,6 +1010,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
is CardItem.Mib -> item.card.cardTypeDesc
|
is CardItem.Mib -> item.card.cardTypeDesc
|
||||||
is CardItem.Bml -> item.account.accountTypeName
|
is CardItem.Bml -> item.account.accountTypeName
|
||||||
}
|
}
|
||||||
|
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
val nfcAvailable = android.nfc.NfcAdapter.getDefaultAdapter(requireContext()) != null
|
||||||
|
binding.btnTapToPay.isEnabled = !isInactiveBml && nfcAvailable
|
||||||
|
binding.btnScanToPay.isEnabled = !isInactiveBml
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onBackPressed(): Boolean {
|
fun onBackPressed(): Boolean {
|
||||||
@@ -887,7 +1092,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
|
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
|
||||||
else ivCardImage.setImageDrawable(null)
|
else ivCardImage.setImageDrawable(null)
|
||||||
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||||
itemView.alpha = 1f
|
itemView.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
|
||||||
}
|
}
|
||||||
is CardItem.Bml -> {
|
is CardItem.Bml -> {
|
||||||
tvCardOwner.text = item.account.accountBriefName
|
tvCardOwner.text = item.account.accountBriefName
|
||||||
@@ -1022,9 +1227,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
|
|
||||||
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
|
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
|
||||||
"CHST0" -> null
|
"CHST0" -> null
|
||||||
|
"CHST20" -> "Temporary blocked by client"
|
||||||
else -> cardStatus
|
else -> cardStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun isMibCardActive(cardStatus: String): Boolean = cardStatus == "CHST0"
|
||||||
|
fun isMibCardFrozen(cardStatus: String): Boolean = cardStatus == "CHST20"
|
||||||
|
|
||||||
fun bindCardStatus(tv: TextView, statusLabel: String?) {
|
fun bindCardStatus(tv: TextView, statusLabel: String?) {
|
||||||
if (statusLabel == null) { tv.visibility = View.GONE; return }
|
if (statusLabel == null) { tv.visibility = View.GONE; return }
|
||||||
tv.visibility = View.VISIBLE
|
tv.visibility = View.VISIBLE
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class SettingsFragment : Fragment() {
|
|||||||
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
||||||
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
||||||
|
SettingsItem(R.drawable.ic_bell_filled, R.string.settings_notifications, R.string.settings_desc_notifications) { SettingsNotificationsFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
||||||
SettingsItem(R.drawable.ic_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
|
SettingsItem(R.drawable.ic_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.SwitchCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.service.NotificationPollingService
|
||||||
|
|
||||||
|
class SettingsNotificationsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var switchView: SwitchCompat? = null
|
||||||
|
|
||||||
|
// Step 1: notification permission — on grant, proceed to battery opt check
|
||||||
|
private val permissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) checkBatteryOptimization() else switchView?.isChecked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: battery optimization — proceed to enableService regardless of user choice
|
||||||
|
private val batteryOptLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) {
|
||||||
|
enableService()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val scroll = ScrollView(ctx).apply { clipToPadding = false }
|
||||||
|
|
||||||
|
val col = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
val p = (20 * dp).toInt()
|
||||||
|
setPadding(p, p, p, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section header
|
||||||
|
col.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_section)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
|
||||||
|
setPadding(0, 0, 0, (12 * dp).toInt())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enable toggle row
|
||||||
|
val sw = SwitchCompat(ctx).apply {
|
||||||
|
isChecked = prefs.getBoolean(PREF_ENABLED, false)
|
||||||
|
}
|
||||||
|
switchView = sw
|
||||||
|
sw.setOnCheckedChangeListener { _, on -> if (on) requestEnableNotifications() else disableService() }
|
||||||
|
|
||||||
|
val toggleRow = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
val vp = (10 * dp).toInt()
|
||||||
|
setPadding(0, vp, 0, vp)
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_enable)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_enable_desc)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.65f
|
||||||
|
})
|
||||||
|
toggleRow.addView(textCol)
|
||||||
|
toggleRow.addView(sw.apply {
|
||||||
|
layoutParams = (layoutParams as? LinearLayout.LayoutParams ?: LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)).also { it.marginStart = (12 * dp).toInt() }
|
||||||
|
})
|
||||||
|
col.addView(toggleRow)
|
||||||
|
|
||||||
|
// Description
|
||||||
|
col.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_description)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.65f
|
||||||
|
setPadding(0, (4 * dp).toInt(), 0, (20 * dp).toInt())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notification channels nav row — same style as settings menu items
|
||||||
|
val colPad = (20 * dp).toInt()
|
||||||
|
val navRow = inflater.inflate(R.layout.item_more_nav, col, false).apply {
|
||||||
|
layoutParams = (layoutParams as LinearLayout.LayoutParams).apply {
|
||||||
|
marginStart = -colPad
|
||||||
|
marginEnd = -colPad
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
findViewById<ImageView>(R.id.ivIcon).setImageResource(R.drawable.ic_bell_filled)
|
||||||
|
findViewById<TextView>(R.id.tvLabel).setText(R.string.settings_notif_open_system)
|
||||||
|
findViewById<TextView>(R.id.tvDescription).setText(R.string.settings_notif_channels_desc)
|
||||||
|
setOnClickListener {
|
||||||
|
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
col.addView(navRow)
|
||||||
|
|
||||||
|
scroll.addView(col)
|
||||||
|
return scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val basePad = view.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
|
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("bottom_nav", false)
|
||||||
|
val nav = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePad + if (isBottom) 0 else nav.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
switchView = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enable flow ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun requestEnableNotifications() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkBatteryOptimization()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkBatteryOptimization() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||||
|
batteryOptLauncher.launch(
|
||||||
|
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:${ctx.packageName}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableService() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean(PREF_ENABLED, true).apply()
|
||||||
|
ctx.startForegroundService(Intent(ctx, NotificationPollingService::class.java))
|
||||||
|
switchView?.isChecked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableService() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean(PREF_ENABLED, false).apply()
|
||||||
|
ctx.stopService(Intent(ctx, NotificationPollingService::class.java))
|
||||||
|
switchView?.isChecked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREF_ENABLED = "notifications_enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ object CardsCache {
|
|||||||
put("phoneNumber", c.phoneNumber)
|
put("phoneNumber", c.phoneNumber)
|
||||||
put("cardHolderName", c.cardHolderName)
|
put("cardHolderName", c.cardHolderName)
|
||||||
put("loginTag", c.loginTag)
|
put("loginTag", c.loginTag)
|
||||||
|
put("profileId", c.profileId)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -45,7 +46,8 @@ object CardsCache {
|
|||||||
customerId = o.optString("customerId"),
|
customerId = o.optString("customerId"),
|
||||||
phoneNumber = o.optString("phoneNumber"),
|
phoneNumber = o.optString("phoneNumber"),
|
||||||
cardHolderName = o.optString("cardHolderName"),
|
cardHolderName = o.optString("cardHolderName"),
|
||||||
loginTag = o.optString("loginTag")
|
loginTag = o.optString("loginTag"),
|
||||||
|
profileId = o.optString("profileId")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
|
|
||||||
<!-- Bell body (white) -->
|
<!-- Bell body -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="@android:color/white"
|
android:fillColor="?attr/colorControlNormal"
|
||||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||||
|
|
||||||
<!-- Unread notification dot (red) -->
|
<!-- Unread notification dot (red) -->
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -192,6 +192,17 @@
|
|||||||
<string name="settings_desc_appearance">Theme, language, and display options</string>
|
<string name="settings_desc_appearance">Theme, language, and display options</string>
|
||||||
<string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string>
|
<string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string>
|
||||||
<string name="settings_desc_storage">Manage cached data and storage usage</string>
|
<string name="settings_desc_storage">Manage cached data and storage usage</string>
|
||||||
|
<string name="settings_notifications">Notifications</string>
|
||||||
|
<string name="settings_desc_notifications">Background alerts for new bank activity</string>
|
||||||
|
<string name="settings_notif_section">Background Polling</string>
|
||||||
|
<string name="settings_notif_enable">Enable background notifications</string>
|
||||||
|
<string name="settings_notif_enable_desc">Receive alerts for new transactions and activity</string>
|
||||||
|
<string name="settings_notif_description">Keeps the app running in the background and notifies you of new bank activity. A persistent status bar notification is shown while active — you can silence or hide it in notification channels.</string>
|
||||||
|
<string name="settings_notif_open_system">Notification channels</string>
|
||||||
|
<string name="settings_notif_channels_desc">Manage sounds, alerts, and silence the background service notification</string>
|
||||||
|
<string name="notif_service_title">Thijooree</string>
|
||||||
|
<string name="notif_service_desc">Checking for new bank notifications</string>
|
||||||
|
<string name="notif_channel_service">Background service</string>
|
||||||
<string name="settings_about">About</string>
|
<string name="settings_about">About</string>
|
||||||
<string name="settings_desc_about">App info, version, and legal</string>
|
<string name="settings_desc_about">App info, version, and legal</string>
|
||||||
<string name="about_version">Version %s</string>
|
<string name="about_version">Version %s</string>
|
||||||
@@ -358,7 +369,17 @@
|
|||||||
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
|
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
|
||||||
<string name="card_action_change_pin">Change PIN</string>
|
<string name="card_action_change_pin">Change PIN</string>
|
||||||
<string name="card_action_freeze">Freeze</string>
|
<string name="card_action_freeze">Freeze</string>
|
||||||
|
<string name="card_action_unfreeze">Unfreeze</string>
|
||||||
<string name="card_action_block">Block</string>
|
<string name="card_action_block">Block</string>
|
||||||
|
<string name="card_freeze_confirm_title">Freeze card?</string>
|
||||||
|
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
|
||||||
|
<string name="card_unfreeze_confirm_title">Unfreeze card?</string>
|
||||||
|
<string name="card_unfreeze_confirm_message">This will re-enable the card for transactions.</string>
|
||||||
|
<string name="card_freeze_success">Card frozen</string>
|
||||||
|
<string name="card_unfreeze_success">Card unfrozen</string>
|
||||||
|
<string name="card_freeze_failed">Failed to update card status</string>
|
||||||
|
<string name="card_freeze_comments_hint">Reason (optional)</string>
|
||||||
|
<string name="card_status_temp_blocked">Temporary blocked by client</string>
|
||||||
<string name="cards_empty">No cards found</string>
|
<string name="cards_empty">No cards found</string>
|
||||||
|
|
||||||
<!-- Connectivity banner -->
|
<!-- Connectivity banner -->
|
||||||
|
|||||||
Reference in New Issue
Block a user