Protect locked previews and filter logs by app
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<String, LoggedApp>()
|
||||
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<LogRow> {
|
||||
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,
|
||||
|
||||
@@ -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<String> = prefs.getStringSet("packages", emptySet()).orEmpty().toSet()
|
||||
fun save(packages: Set<String>) = prefs.edit { putStringSet("packages", packages) }
|
||||
fun clear() = prefs.edit { remove("packages") }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,5h18l-7,8v5l-4,2v-7z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,12 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,5h18l-7,8v5l-4,2v-7z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M18,16m-3,0a3,3 0,1 0,6 0a3,3 0,1 0,-6 0" />
|
||||
</vector>
|
||||
Reference in New Issue
Block a user