diff --git a/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt index 75717f4..61486f4 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/MainActivity.kt @@ -8,6 +8,7 @@ import android.content.res.Configuration import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.WindowManager import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity @@ -23,23 +24,27 @@ import androidx.fragment.app.commit import com.google.android.material.navigation.NavigationView import se.ajpanton.notificationlog.databinding.ActivityMainBinding import se.ajpanton.notificationlog.settings.AppLockStore +import se.ajpanton.notificationlog.settings.LogAppFilterStore class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { private lateinit var binding: ActivityMainBinding private lateinit var drawerToggle: ActionBarDrawerToggle private var permanentSidebar = false private var needsUnlock = true + private lateinit var appLockStore: AppLockStore private var currentItemId = R.id.nav_view_logs private var drawerNavigationBasePaddingLeft: Int? = null private var permanentNavigationBasePaddingLeft: Int? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + appLockStore = AppLockStore(this) + updateSensitiveWindowPolicy(appLockStore.enabled) // Keep every app surface below the system bars rather than relying on edge-to-edge drawing. WindowCompat.setDecorFitsSystemWindows(window, true) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) - showLockedOverlay(AppLockStore(this).enabled) + showLockedOverlay(appLockStore.enabled) val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode @@ -85,7 +90,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte override fun onResume() { super.onResume() - if (needsUnlock && AppLockStore(this).enabled) { + if (needsUnlock && appLockStore.enabled) { showLockedOverlay(true) requestUnlock() } else { @@ -97,7 +102,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte super.onStop() if (!isChangingConfigurations) { needsUnlock = true - if (AppLockStore(this).enabled) showLockedOverlay(true) + if (appLockStore.enabled) showLockedOverlay(true) } } @@ -119,6 +124,18 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte binding.lockOverlay.visibility = if (visible) View.VISIBLE else View.GONE } + /** Called immediately when the Settings switch changes, before a task snapshot can be captured. */ + fun setAppLockEnabled(enabled: Boolean) { + appLockStore.enabled = enabled + updateSensitiveWindowPolicy(enabled) + } + + private fun updateSensitiveWindowPolicy(locked: Boolean) { + setRecentsScreenshotEnabled(!locked) + if (locked) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + override fun onNavigationItemSelected(item: MenuItem): Boolean { navigateTo(item.itemId) if (!permanentSidebar) { @@ -146,6 +163,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte runOnCommit(::applyPageTitleVisibility) } title = getString(page.titleRes) + invalidateOptionsMenu() } private fun setupNavigationView(navigationView: NavigationView) { @@ -289,6 +307,38 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte drawerToggle.drawerArrowDrawable.color = getColor(R.color.on_primary) } + fun updateLogFilterAction() { + if (currentItemId == R.id.nav_view_logs) invalidateOptionsMenu() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + return updateToolbarActions(menu) + } + + override fun onPrepareOptionsMenu(menu: Menu): Boolean { + menu.clear() + return updateToolbarActions(menu) + } + + private fun updateToolbarActions(menu: Menu): Boolean { + if (currentItemId != R.id.nav_view_logs) return false + val active = LogAppFilterStore(this).selectedPackages().isNotEmpty() + menu.add(Menu.NONE, MENU_LOG_FILTER, Menu.NONE, "Filter logs by app").apply { + setIcon(if (active) R.drawable.ic_log_filter_active else R.drawable.ic_log_filter) + icon?.setTint(getColor(R.color.on_primary)) + setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS) + } + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == MENU_LOG_FILTER) { + (supportFragmentManager.findFragmentById(R.id.content_frame) as? ViewLogsFragment)?.showAppFilter() + return true + } + return super.onOptionsItemSelected(item) + } + private enum class Page( val menuId: Int, val titleRes: Int, @@ -307,6 +357,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte private companion object { const val STATE_CURRENT_ITEM_ID = "current_item_id" + const val MENU_LOG_FILTER = 1 const val PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER = 1.1f } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 5def6e5..ff32c98 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -19,6 +19,7 @@ import se.ajpanton.notificationlog.capture.NotificationCaptureService import se.ajpanton.notificationlog.export.LogExporter import se.ajpanton.notificationlog.settings.StorageLimits import se.ajpanton.notificationlog.settings.StorageLimitsStore +import se.ajpanton.notificationlog.settings.LogAppFilterStore import java.util.zip.ZipOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -38,7 +39,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } val lockStore = AppLockStore(requireContext()) binding!!.appLock.isChecked = lockStore.enabled - binding!!.appLock.setOnCheckedChangeListener { _, enabled -> lockStore.enabled = enabled } + binding!!.appLock.setOnCheckedChangeListener { _, enabled -> + (activity as? MainActivity)?.setAppLockEnabled(enabled) ?: run { lockStore.enabled = enabled } + } bindStorageLimits() } override fun onDestroyView() { binding = null; super.onDestroyView() } @@ -75,16 +78,29 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) { } private fun confirmClearLogs() { - MaterialAlertDialogBuilder(requireContext()) + val filterStore = LogAppFilterStore(requireContext()) + val dialog = MaterialAlertDialogBuilder(requireContext()) .setTitle("Clear logs?") .setMessage("This permanently removes every stored notification log and copied image.") .setNegativeButton("Cancel", null) .setPositiveButton("Clear") { _, _ -> - viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { - EncryptedNotificationLogStore(requireContext().applicationContext).clear() - } + clearLogs() } - .show() + if (filterStore.selectedPackages().isNotEmpty()) { + dialog.setNeutralButton("Clear and reset filter") { _, _ -> + filterStore.clear() + (activity as? MainActivity)?.updateLogFilterAction() + clearLogs() + } + } + dialog.show() + } + + private fun clearLogs() { + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + EncryptedNotificationLogStore(appContext).clear() + } } private fun chooseExportFormat() { diff --git a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt index 28e0f9a..9f26d65 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/ViewLogsFragment.kt @@ -26,6 +26,7 @@ import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogViewSettings import se.ajpanton.notificationlog.settings.LogViewSettingsStore +import se.ajpanton.notificationlog.settings.LogAppFilterStore import se.ajpanton.notificationlog.settings.TimestampFormatter import se.ajpanton.notificationlog.settings.DisplayEvent import se.ajpanton.notificationlog.capture.NotificationCaptureService @@ -49,10 +50,12 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { private var noMoreRows = false private var loadGeneration = 0 private val logAdapter = LogAdapter() + private lateinit var appFilterStore: LogAppFilterStore override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding = FragmentViewLogsBinding.bind(view) + appFilterStore = LogAppFilterStore(requireContext()) binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) } binding!!.logList.layoutManager = LinearLayoutManager(requireContext()) binding!!.logList.adapter = logAdapter @@ -82,7 +85,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { } private fun loadLogs(reset: Boolean) { - if (loading) return + if (loading && !reset) return if (!reset && noMoreRows) return val context = requireContext().applicationContext if (reset) { @@ -98,30 +101,83 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { val page = withContext(Dispatchers.IO) { EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE) } - if (generation == loadGeneration) { - nextCursor = page.nextCursor - noMoreRows = nextCursor == null - val addedRows = page.entries.map(::LogRow) - rows += addedRows - binding?.let(::render) - } - loading = false - binding?.logsRefresh?.isRefreshing = false + if (generation != loadGeneration) return@launch + nextCursor = page.nextCursor + noMoreRows = nextCursor == null + val visibleBefore = visibleRows(LogViewSettingsStore(requireContext()).load()).size + rows += page.entries.map(::LogRow) + binding?.let(::render) + val visibleAfter = visibleRows(LogViewSettingsStore(requireContext()).load()).size + val skipFilteredPage = appFilterStore.selectedPackages().isNotEmpty() && + visibleAfter == visibleBefore && !noMoreRows + loading = false + binding?.logsRefresh?.isRefreshing = false + if (skipFilteredPage) loadLogs(reset = false) } } private fun render(view: FragmentViewLogsBinding) { - view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE + val settings = LogViewSettingsStore(requireContext()).load() + val visibleRows = visibleRows(settings) + view.emptyView.visibility = if (visibleRows.isEmpty()) View.VISIBLE else View.GONE view.emptyView.text = if (hasNotificationAccess()) { - "No logs yet." + if (appFilterStore.selectedPackages().isEmpty()) "No logs yet." else "No logs match the app filter." } else { "Notification access is disabled. Enable it in Settings." } - val settings = LogViewSettingsStore(requireContext()).load() - val visibleRows = rows.filter { eventFor(it.entry.action) in settings.visibleEvents } logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings)) } + /** Called by MainActivity's header action. Selections persist until Reset is chosen. */ + fun showAppFilter() { + val appContext = requireContext().applicationContext + viewLifecycleOwner.lifecycleScope.launch { + val apps = withContext(Dispatchers.IO) { + val byPackage = linkedMapOf() + EncryptedNotificationLogStore(appContext).forEachNewest { entry -> + byPackage.putIfAbsent(entry.packageName, LoggedApp(entry.appName, entry.packageName)) + } + byPackage.values.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.name }) + } + if (!isAdded) return@launch + val selected = appFilterStore.selectedPackages().toMutableSet() + var changed = false + val dialog = MaterialAlertDialogBuilder(requireContext()) + .setTitle("Filter logs by app") + .setMultiChoiceItems( + apps.map { "${it.name}\n${it.packageName}" }.toTypedArray(), + apps.map { it.packageName in selected }.toBooleanArray(), + ) { _, which, checked -> + if (checked) selected += apps[which].packageName else selected -= apps[which].packageName + appFilterStore.save(selected) + changed = true + } + .setNeutralButton("Reset") { _, _ -> + appFilterStore.clear() + changed = true + } + .setPositiveButton("Close", null) + .create() + dialog.setOnDismissListener { + if (changed) refreshAppFilter() + } + dialog.show() + } + } + + private fun refreshAppFilter() { + (activity as? MainActivity)?.updateLogFilterAction() + loadLogs(reset = true) + } + + private fun visibleRows(settings: LogViewSettings): List { + val selectedPackages = appFilterStore.selectedPackages() + return rows.filter { row -> + eventFor(row.entry.action) in settings.visibleEvents && + (selectedPackages.isEmpty() || row.entry.packageName in selectedPackages) + } + } + private fun logRowView(row: LogRow, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply { orientation = LinearLayout.HORIZONTAL gravity = android.view.Gravity.TOP @@ -463,6 +519,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) { ) private data class LogRow(val entry: NotificationLogEntry) + private data class LoggedApp(val name: String, val packageName: String) private data class MetadataValue( val text: String, val textSize: Float, diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/LogAppFilterStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/LogAppFilterStore.kt new file mode 100644 index 0000000..810ef11 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/LogAppFilterStore.kt @@ -0,0 +1,12 @@ +package se.ajpanton.notificationlog.settings + +import android.content.Context +import androidx.core.content.edit + +class LogAppFilterStore(context: Context) { + private val prefs = context.getSharedPreferences("log-app-filter", Context.MODE_PRIVATE) + + fun selectedPackages(): Set = prefs.getStringSet("packages", emptySet()).orEmpty().toSet() + fun save(packages: Set) = prefs.edit { putStringSet("packages", packages) } + fun clear() = prefs.edit { remove("packages") } +} diff --git a/app/src/main/res/drawable/ic_log_filter.xml b/app/src/main/res/drawable/ic_log_filter.xml new file mode 100644 index 0000000..c09d154 --- /dev/null +++ b/app/src/main/res/drawable/ic_log_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_log_filter_active.xml b/app/src/main/res/drawable/ic_log_filter_active.xml new file mode 100644 index 0000000..4c07d24 --- /dev/null +++ b/app/src/main/res/drawable/ic_log_filter_active.xml @@ -0,0 +1,12 @@ + + + +