From 6467d1ab6ccabd179b7e632f14a0cd35da49184f Mon Sep 17 00:00:00 2001 From: ajp_anton Date: Mon, 27 Jul 2026 22:55:09 +0000 Subject: [PATCH] Use lifecycle-scoped IO for screen work --- app/build.gradle.kts | 1 + .../notificationlog/EventSettingsFragment.kt | 24 +++++++------ .../ImageViewerDialogFragment.kt | 36 ++++++++++--------- .../notificationlog/SettingsFragment.kt | 32 +++++++++++++---- gradle/libs.versions.toml | 2 ++ 5 files changed, 61 insertions(+), 34 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8116950..719a77d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -90,6 +90,7 @@ dependencies { implementation(libs.drawerlayout) implementation(libs.recyclerview) implementation(libs.swiperefreshlayout) + implementation(libs.lifecycle.runtime.ktx) testImplementation(libs.junit) } diff --git a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt index 231deb5..6057300 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/EventSettingsFragment.kt @@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.lifecycle.lifecycleScope import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.switchmaterial.SwitchMaterial @@ -31,6 +32,9 @@ import se.ajpanton.notificationlog.settings.AppFilterSettingsStore import se.ajpanton.notificationlog.settings.LoggingRuleStore import se.ajpanton.notificationlog.settings.LoggingType import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) { private var binding: FragmentEventSettingsBinding? = null @@ -121,8 +125,9 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) { val rule = store.load() val seen = SeenApps.snapshot() val context = requireContext().applicationContext - Thread { - val apps = context.packageManager.getInstalledApplications(0).map { info -> + viewLifecycleOwner.lifecycleScope.launch { + val apps = withContext(Dispatchers.IO) { + context.packageManager.getInstalledApplications(0).map { info -> ListedApp( label = context.packageManager.getApplicationLabel(info).toString(), packageName = info.packageName, @@ -130,16 +135,15 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) { selected = info.packageName in rule.selectedPackages, hasEventOverride = perAppEventSettings.hasOverride(info.packageName), ) - } - val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst) - activity?.runOnUiThread { - if (binding === currentBinding && generation == appLoadGeneration) { - appAdapter.submit(items) - currentBinding.appListLoading.visibility = View.GONE - currentBinding.appListRefresh.isRefreshing = false } } - }.start() + val items = AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst) + if (binding === currentBinding && generation == appLoadGeneration) { + appAdapter.submit(items) + currentBinding.appListLoading.visibility = View.GONE + currentBinding.appListRefresh.isRefreshing = false + } + } } private fun setPackageSelected(packageName: String, selected: Boolean) { diff --git a/app/src/main/java/se/ajpanton/notificationlog/ImageViewerDialogFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ImageViewerDialogFragment.kt index ecca03a..d21458e 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ImageViewerDialogFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ImageViewerDialogFragment.kt @@ -19,7 +19,11 @@ import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope import se.ajpanton.notificationlog.data.EncryptedImageStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** Full-screen image viewer kept within MainActivity so opening it does not re-lock the app. */ class ImageViewerDialogFragment : DialogFragment() { @@ -67,27 +71,25 @@ class ImageViewerDialogFragment : DialogFragment() { val targetWidth = resources.displayMetrics.widthPixels val targetHeight = resources.displayMetrics.heightPixels val appContext = requireContext().applicationContext - Thread { - val bitmap = runCatching { + lifecycleScope.launch { + val bitmap = withContext(Dispatchers.IO) { runCatching { EncryptedImageStore(appContext).read(imageId)?.let { bytes -> decodeForViewer(bytes, targetWidth, targetHeight) } - }.getOrNull() - activity?.runOnUiThread { - if (!isAdded) return@runOnUiThread - progress.visibility = View.GONE - if (bitmap == null) { - root.addView(TextView(requireContext()).apply { - text = "[image]" - setTextColor(Color.WHITE) - textSize = 18f - }, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)) - } else { - image.setImageBitmap(bitmap) - image.visibility = View.VISIBLE - } + }.getOrNull() } + if (!isAdded) return@launch + progress.visibility = View.GONE + if (bitmap == null) { + root.addView(TextView(requireContext()).apply { + text = "[image]" + setTextColor(Color.WHITE) + textSize = 18f + }, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER)) + } else { + image.setImageBitmap(bitmap) + image.visibility = View.VISIBLE } - }.start() + } } private fun decodeForViewer(bytes: ByteArray, targetWidth: Int, targetHeight: Int): Bitmap? { diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 5ed5bf6..19bc8d0 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.provider.Settings import android.view.View import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding import se.ajpanton.notificationlog.settings.LogField @@ -19,6 +20,9 @@ import se.ajpanton.notificationlog.export.LogExporter import se.ajpanton.notificationlog.settings.StorageLimits import se.ajpanton.notificationlog.settings.StorageLimitsStore import java.util.zip.ZipOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class SettingsFragment : Fragment(R.layout.fragment_settings) { private var binding: FragmentSettingsBinding? = null @@ -64,7 +68,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { } store.save(StorageLimits(logLimit * StorageLimitsStore.MEBIBYTE, imageLimit * StorageLimitsStore.MEBIBYTE)) val appContext = requireContext().applicationContext - Thread { EncryptedNotificationLogStore(appContext).enforceLimits() }.start() + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + EncryptedNotificationLogStore(appContext).enforceLimits() + } } } @@ -73,7 +79,11 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { .setTitle("Clear logs?") .setMessage("This permanently removes every stored notification log and copied image.") .setNegativeButton("Cancel", null) - .setPositiveButton("Clear") { _, _ -> EncryptedNotificationLogStore(requireContext()).clear() } + .setPositiveButton("Clear") { _, _ -> + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + EncryptedNotificationLogStore(requireContext().applicationContext).clear() + } + } .show() } @@ -98,17 +108,25 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { private fun writeExport(uri: Uri) { val format = pendingExport ?: return val context = requireContext().applicationContext - Thread { - val logStore = EncryptedNotificationLogStore(context) - val settings = LogViewSettingsStore(context).load() - context.contentResolver.openOutputStream(uri)?.use { output -> + viewLifecycleOwner.lifecycleScope.launch { + val result = runCatching { + withContext(Dispatchers.IO) { + val logStore = EncryptedNotificationLogStore(context) + val settings = LogViewSettingsStore(context).load() + context.contentResolver.openOutputStream(uri)?.use { output -> when (format) { ExportFormat.CSV -> writeCsv(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context) ExportFormat.FORMATTED -> writeFormatted(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context) ExportFormat.HTML_ZIP -> writeHtmlZip(ZipOutputStream(output), logStore, settings, context) } } - }.start() + } + } + pendingExport = null + result.exceptionOrNull()?.let { error -> + android.widget.Toast.makeText(requireContext(), "Export failed: ${error.message ?: "unknown error"}", android.widget.Toast.LENGTH_LONG).show() + } + } } private fun writeCsv( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 108083e..eac4c32 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ material = "1.10.0" drawerLayout = "1.2.0" recyclerView = "1.3.2" swipeRefreshLayout = "1.1.0" +lifecycle = "2.8.7" junit = "4.13.2" [libraries] @@ -18,6 +19,7 @@ material = { group = "com.google.android.material", name = "material", version.r drawerlayout = { group = "androidx.drawerlayout", name = "drawerlayout", version.ref = "drawerLayout" } recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerView" } swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swipeRefreshLayout" } +lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins]