diff --git a/.kotlin/errors/errors-1781082977712.log b/.kotlin/errors/errors-1781082977712.log
new file mode 100644
index 0000000..964e788
--- /dev/null
+++ b/.kotlin/errors/errors-1781082977712.log
@@ -0,0 +1,43 @@
+kotlin version: 2.1.21
+error message: Incremental compilation failed: null
+java.io.EOFException
+ at java.base/java.io.DataInputStream.readFully(Unknown Source)
+ at org.jetbrains.kotlin.incremental.storage.ProtoMapValueExternalizer.read(externalizers.kt:136)
+ at org.jetbrains.kotlin.incremental.storage.ProtoMapValueExternalizer.read(externalizers.kt:120)
+ at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.doGet(PersistentMapImpl.java:680)
+ at org.jetbrains.kotlin.com.intellij.util.io.PersistentMapImpl.get(PersistentMapImpl.java:613)
+ at org.jetbrains.kotlin.com.intellij.util.io.PersistentHashMap.get(PersistentHashMap.java:196)
+ at org.jetbrains.kotlin.incremental.storage.LazyStorage.get(LazyStorage.kt:76)
+ at org.jetbrains.kotlin.incremental.storage.InMemoryStorage.get(InMemoryStorage.kt:68)
+ at org.jetbrains.kotlin.incremental.IncrementalJvmCache$ProtoMap.putAndCollect(IncrementalJvmCache.kt:381)
+ at org.jetbrains.kotlin.incremental.IncrementalJvmCache$ProtoMap.process(IncrementalJvmCache.kt:353)
+ at org.jetbrains.kotlin.incremental.IncrementalJvmCache.saveClassToCache(IncrementalJvmCache.kt:195)
+ at org.jetbrains.kotlin.incremental.IncrementalJvmCache.saveFileToCache(IncrementalJvmCache.kt:119)
+ at org.jetbrains.kotlin.incremental.BuildUtilKt.updateIncrementalCache(buildUtil.kt:110)
+ at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.updateCaches(IncrementalJvmCompilerRunner.kt:374)
+ at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.updateCaches(IncrementalJvmCompilerRunner.kt:75)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:553)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:431)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally$lambda$10$compile(IncrementalCompilerRunner.kt:258)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:276)
+ at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:128)
+ at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:678)
+ at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
+ at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1805)
+ at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source)
+ at java.base/java.lang.reflect.Method.invoke(Unknown Source)
+ at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source)
+ at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
+ at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source)
+ at java.base/java.security.AccessController.doPrivileged(Unknown Source)
+ at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source)
+ at java.base/java.security.AccessController.doPrivileged(Unknown Source)
+ at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source)
+ at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
+ at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
+ at java.base/java.lang.Thread.run(Unknown Source)
+
+
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a4b46a5..5972710 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,10 @@
+
+
+
+
@@ -69,6 +73,11 @@
android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" />
+
+
+ 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"
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt
index 8ec0ad0..a657a17 100644
--- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt
+++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt
@@ -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_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_bell, 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_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
)
diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsNotificationsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsNotificationsFragment.kt
new file mode 100644
index 0000000..a37a3b9
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsNotificationsFragment.kt
@@ -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(R.id.ivIcon).setImageResource(R.drawable.ic_bell)
+ findViewById(R.id.tvLabel).setText(R.string.settings_notif_open_system)
+ findViewById(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"
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f5b9154..1700a98 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -192,6 +192,17 @@
Theme, language, and display options
App lock, PIN, and security preferences
Manage cached data and storage usage
+ Notifications
+ Background alerts for new bank activity
+ Background Polling
+ Enable background notifications
+ Receive alerts for new transactions and activity
+ 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.
+ Notification channels
+ Manage sounds, alerts, and silence the background service notification
+ Thijooree
+ Checking for new bank notifications
+ Background service
About
App info, version, and legal
Version %s