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