background service and push notifications
Auto Tag on Version Change / check-version (push) Failing after 14m1s

This commit is contained in:
2026-06-10 14:19:43 +05:00
parent 80bbacc130
commit 05430f043a
6 changed files with 441 additions and 0 deletions
+43
View File
@@ -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)
+9
View File
@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.NFC" />
<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" />
@@ -69,6 +73,11 @@
android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" />
<service
android:name=".service.NotificationPollingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".nfc.BmlHostCardEmulatorService"
android:exported="true"
@@ -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"
}
}
@@ -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() },
)
@@ -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)
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"
}
}
+11
View File
@@ -192,6 +192,17 @@
<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_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_desc_about">App info, version, and legal</string>
<string name="about_version">Version %s</string>