56 Commits
Author SHA1 Message Date
ajp_anton 9832b80e64 Release 1.1 2026-08-08 06:55:34 +00:00
ajp_anton 96bd5c4d17 Keep selected app names after clearing logs 2026-08-08 06:21:50 +00:00
ajp_anton d85f9050cc Clarify previous contents in edited logs 2026-08-08 06:05:50 +00:00
ajp_anton 653080090b Avoid suppressing message updates as progress 2026-08-08 06:00:56 +00:00
ajp_anton 6eaf2a8784 Capture later structured messaging updates 2026-08-08 04:33:16 +00:00
ajp_anton 5b8de63aac Protect locked previews and filter logs by app 2026-08-08 04:33:16 +00:00
ajp_anton 05d682601a Release 1.0 2026-07-28 13:02:22 +00:00
ajp_anton 02b4862294 Clarify timestamp defaults and app filter overrides 2026-07-28 04:35:47 +00:00
ajp_anton bb822736ab Separate dropdown popups from settings pages 2026-07-28 04:06:32 +00:00
ajp_anton 591527750d Label storage limits in MiB 2026-07-28 04:02:17 +00:00
ajp_anton 5f6f169d3e Clarify image support in log exports 2026-07-28 04:01:10 +00:00
ajp_anton a107c481a2 Release 0.2 2026-07-28 03:56:25 +00:00
ajp_anton 2a330cc831 Streamline UI updates and cleanup maintenance debt 2026-07-27 23:21:59 +00:00
ajp_anton c28144b9ed Keep recycled log list lint clean 2026-07-27 23:02:25 +00:00
ajp_anton 21a228a4f6 Recycle paged log rows 2026-07-27 23:01:51 +00:00
ajp_anton 66ab982a7e Page encrypted logs with cursors 2026-07-27 22:55:58 +00:00
ajp_anton 6467d1ab6c Use lifecycle-scoped IO for screen work 2026-07-27 22:55:09 +00:00
ajp_anton a86ea52006 Resolve concrete lint and backup configuration issues 2026-07-27 22:54:06 +00:00
ajp_anton f31a7c8efe Avoid inactive listener work and eager image encoding 2026-07-27 22:51:17 +00:00
ajp_anton ebb05acc02 Avoid duplicate BigText notification content 2026-07-27 22:32:28 +00:00
ajp_anton 6a41b922e3 Filter group summary cancellation logs 2026-07-27 22:08:25 +00:00
ajp_anton 6116c13e00 Add configurable timestamp date and clock formats 2026-07-27 22:06:47 +00:00
ajp_anton 125ba99af9 Highlight rows after collapsing logs 2026-07-27 21:57:32 +00:00
ajp_anton f97dad8fb2 Anchor log collapse at the current viewport 2026-07-27 21:53:10 +00:00
ajp_anton 68e780c205 Tighten gap between log columns 2026-07-27 21:41:47 +00:00
ajp_anton e2a04aa950 Tighten view logs horizontal insets 2026-07-27 21:41:36 +00:00
ajp_anton 94250286e6 Restyle log rows as a responsive table 2026-07-27 21:36:03 +00:00
ajp_anton 627fda6bf8 Restore log position after collapse layout 2026-07-27 21:26:09 +00:00
ajp_anton 2584aa77be Prevent panning before quick scale 2026-07-27 21:23:27 +00:00
ajp_anton 1145713584 Use Android quick scale for image zoom 2026-07-27 21:21:26 +00:00
ajp_anton 0f3b4397f3 Avoid conflicting double-tap zoom gestures 2026-07-27 21:15:26 +00:00
ajp_anton 2efd923f7f Improve full-screen image viewer gestures 2026-07-27 21:05:20 +00:00
ajp_anton 9f880c5f1c Request listener rebind on startup 2026-07-27 20:54:22 +00:00
ajp_anton 13a6c9c02e Restore listener after clearing app data 2026-07-27 20:43:00 +00:00
ajp_anton 976ca9596e Preserve log position when collapsing rows 2026-07-27 20:30:53 +00:00
ajp_anton 7db3a370e7 Expand long log metadata on tap 2026-07-27 18:16:29 +00:00
ajp_anton 83a71378fb Add full-screen notification image viewer 2026-07-27 17:43:42 +00:00
ajp_anton d09fb4fa09 Keep image markers after retention trimming 2026-07-27 17:24:15 +00:00
ajp_anton a72c6b9437 Stream notification log exports 2026-07-27 17:14:33 +00:00
ajp_anton 8f21874492 Add configurable chunked storage limits 2026-07-27 14:06:14 +00:00
ajp_anton dca86ae393 Clean stale per-app storage on startup 2026-07-27 13:51:10 +00:00
ajp_anton 1e4315d3dd Add per-app event logging overrides 2026-07-27 13:45:32 +00:00
ajp_anton 55057767c1 Clarify seen and checked app filtering 2026-07-27 13:27:08 +00:00
ajp_anton cad73e33a0 Simplify and organize settings pages 2026-07-27 12:59:26 +00:00
ajp_anton 3dc87b417a Improve grouped and messaging notification capture 2026-07-27 12:03:33 +00:00
ajp_anton 4d84ff98c7 Revert "Avoid duplicated MessagingStyle notification text"
This reverts commit 5c8cb814f6.
2026-07-27 01:25:20 +00:00
ajp_anton 5c8cb814f6 Avoid duplicated MessagingStyle notification text 2026-07-27 01:19:47 +00:00
ajp_anton 931ff5830f Add scroll-to-top control for logs 2026-07-27 01:17:40 +00:00
ajp_anton 607e89938e Show complete metadata in log pills 2026-07-26 23:53:14 +00:00
ajp_anton d190f4aee1 Outline compact log pills 2026-07-26 23:23:31 +00:00
ajp_anton 43905f72f0 Present logs in compact metadata and content pills 2026-07-26 23:22:37 +00:00
ajp_anton 3bc71cdc61 Hide logs behind authentication prompt 2026-07-26 15:15:45 +00:00
ajp_anton 6b93492a0e Declare biometric permission for app lock 2026-07-26 14:43:06 +00:00
ajp_anton d6868b6927 Load event app lists asynchronously 2026-07-26 14:41:37 +00:00
ajp_anton c5f3dade9f Simplify settings navigation and copy controls 2026-07-26 14:38:40 +00:00
ajp_anton 14a96046f7 Refresh logs and move log controls to settings 2026-07-26 14:37:20 +00:00
65 changed files with 2764 additions and 857 deletions
+2
View File
@@ -19,6 +19,8 @@ decisions, physical device testing, and final behaviour are still manually revie
You can choose which event types to record, set app-specific allow/block lists,
and suppress routine progress or timer updates. The log view can be tailored,
expanded for long entries, and exported as CSV, aligned text, or an HTML ZIP.
Only the HTML ZIP includes saved notification images; CSV and aligned text use
an `[image]` marker instead.
## Privacy
+3 -1
View File
@@ -88,7 +88,9 @@ dependencies {
implementation(libs.fragment.ktx)
implementation(libs.material)
implementation(libs.drawerlayout)
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
implementation(libs.recyclerview)
implementation(libs.swiperefreshlayout)
implementation(libs.lifecycle.runtime.ktx)
testImplementation(libs.junit)
}
+10 -2
View File
@@ -1,10 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Required for the all-installed-apps filter; this app is not distributed through Google Play. -->
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<application
android:name=".NotificationLogApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
@@ -1,149 +1,342 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.res.ColorStateList
import android.text.SpannableString
import android.text.Spanned
import android.text.style.StyleSpan
import android.text.style.UnderlineSpan
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.TextView
import androidx.annotation.StringRes
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.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.capture.SeenApps
import se.ajpanton.notificationlog.databinding.FragmentEventSettingsBinding
import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.AppListItem
import se.ajpanton.notificationlog.settings.AppListOrdering
import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.ListedApp
import se.ajpanton.notificationlog.capture.SeenApps
import se.ajpanton.notificationlog.settings.AppFilterSettings
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 EventSettingsFragment : Fragment(R.layout.fragment_event_settings) {
class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
private var binding: FragmentEventSettingsBinding? = null
private lateinit var type: LoggingType
private lateinit var store: LoggingRuleStore
private lateinit var store: AppFilterSettingsStore
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var appAdapter: AppListAdapter
private var appLoadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view)
type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!!
store = LoggingRuleStore(requireContext())
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE))
store = AppFilterSettingsStore(requireContext())
perAppEventSettings = PerAppEventSettingsStore(requireContext())
appAdapter = AppListAdapter(::setPackageSelected, ::showPerAppEventSettings)
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = appAdapter
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
installBottomRefresh()
bindRule()
binding!!.appListRefresh.setOnRefreshListener(::refreshAppList)
}
override fun onDestroyView() {
appLoadGeneration++
binding = null
super.onDestroyView()
}
@Suppress("ClickableViewAccessibility") // RecyclerView has no bottom pull-to-refresh API.
private fun installBottomRefresh() {
val list = binding!!.appList
var touchStartY = 0f
val scroll = binding!!.appScroll
scroll.setOnTouchListener { _, event ->
list.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> touchStartY = event.y
MotionEvent.ACTION_UP -> {
val atBottom = scroll.scrollY >= (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0)
if (atBottom && event.y < touchStartY) {
refreshAppList()
}
}
MotionEvent.ACTION_UP -> if (!list.canScrollVertically(1) && event.y < touchStartY) reloadAppList()
}
false
}
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun bindRule() {
val rule = store.load()
listOf(
binding!!.appRuleToggle,
binding!!.onlySeenToggle,
binding!!.seenFirstToggle,
).forEach { it.setOnCheckedChangeListener(null) }
binding!!.appRuleToggle.isChecked = rule.mode == AppRuleMode.WHITELIST
binding!!.onlySeenToggle.isChecked = rule.onlySeenApps
binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst
updateRuleLabels(rule)
binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked ->
save { copy(mode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) }
}
binding!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } }
binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
reloadAppList()
}
private fun refreshAppList() {
private fun save(change: AppFilterSettings.() -> AppFilterSettings) {
store.save(store.load().change())
bindRule()
}
private fun updateRuleLabels(rule: AppFilterSettings) {
binding!!.appRuleToggle.text = modeLabel(rule.mode)
binding!!.onlySeenToggle.text = "Show only seen and edited apps"
binding!!.seenFirstToggle.text = "Show seen apps first"
}
private fun modeLabel(mode: AppRuleMode): CharSequence {
val text = "Blacklist / Whitelist"
val selected = if (mode == AppRuleMode.BLACKLIST) "Blacklist" else "Whitelist"
val start = text.indexOf(selected)
return SpannableString(text).apply {
setSpan(StyleSpan(android.graphics.Typeface.BOLD), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
setSpan(UnderlineSpan(), start, start + selected.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
private fun reloadAppList() {
val currentBinding = binding ?: return
if (currentBinding.appListRefresh.isRefreshing) {
// A fresh swipe replaces the current snapshot; no live updates occur otherwise.
}
val generation = ++appLoadGeneration
currentBinding.appListLoading.visibility = View.VISIBLE
currentBinding.appListRefresh.isRefreshing = true
currentBinding.appListRefresh.post {
if (binding === currentBinding) {
reloadAppList()
val rule = store.load()
val seen = SeenApps.snapshot()
val context = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val apps = withContext(Dispatchers.IO) {
context.packageManager.getInstalledApplications(0).map { info ->
ListedApp(
label = context.packageManager.getApplicationLabel(info).toString(),
packageName = info.packageName,
seen = info.packageName in seen,
selected = info.packageName in rule.selectedPackages,
hasEventOverride = perAppEventSettings.hasOverride(info.packageName),
)
}
}
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 bindRule() {
val rule = store.ruleFor(type)
listOf(
binding!!.masterToggle,
binding!!.appRuleToggle,
binding!!.onlySeenToggle,
binding!!.seenFirstToggle,
binding!!.routineUpdatesToggle,
).forEach { it.setOnCheckedChangeListener(null) }
binding!!.masterToggle.isChecked = rule.enabled
binding!!.appRuleToggle.isChecked = rule.appRuleMode == AppRuleMode.WHITELIST
binding!!.onlySeenToggle.isChecked = rule.onlySeenApps
binding!!.seenFirstToggle.isChecked = rule.seenAppsFirst
binding!!.seenFirstToggle.isEnabled = !rule.onlySeenApps
binding!!.routineUpdatesToggle.visibility = if (type == LoggingType.EDITS) View.VISIBLE else View.GONE
binding!!.routineUpdatesToggle.isChecked = rule.ignoreRoutineUpdates
updateRuleLabels()
binding!!.masterToggle.setOnCheckedChangeListener { _, checked -> save { copy(enabled = checked) } }
binding!!.appRuleToggle.setOnCheckedChangeListener { _, checked -> save { copy(appRuleMode = if (checked) AppRuleMode.WHITELIST else AppRuleMode.BLACKLIST) } }
binding!!.onlySeenToggle.setOnCheckedChangeListener { _, checked -> save { copy(onlySeenApps = checked) } }
binding!!.seenFirstToggle.setOnCheckedChangeListener { _, checked -> save { copy(seenAppsFirst = checked) } }
binding!!.routineUpdatesToggle.setOnCheckedChangeListener { _, checked -> save { copy(ignoreRoutineUpdates = checked) } }
reloadAppList()
}
private fun save(change: se.ajpanton.notificationlog.settings.LoggingRule.() -> se.ajpanton.notificationlog.settings.LoggingRule) {
store.save(type, store.ruleFor(type).change())
bindRule()
}
private fun updateRuleLabels() {
val rule = store.ruleFor(type)
binding!!.appRuleToggle.text = if (rule.appRuleMode == AppRuleMode.WHITELIST) "Whitelist chosen" else "Blacklist chosen"
binding!!.onlySeenToggle.text = if (rule.onlySeenApps) "Show only seen apps" else "Show all apps"
}
private fun reloadAppList() {
val container = binding!!.appList
container.removeAllViews()
val rule = store.ruleFor(type)
val seen = SeenApps.snapshot()
val apps = requireContext().packageManager.getInstalledApplications(0)
.map { info ->
ListedApp(
label = requireContext().packageManager.getApplicationLabel(info).toString(),
packageName = info.packageName,
seen = info.packageName in seen,
selected = info.packageName in rule.selectedPackages,
)
}
AppListOrdering.items(apps, rule.onlySeenApps, rule.seenAppsFirst).forEach { item ->
container.addView(if (item is AppListItem.App) appView(item.value) else separator())
private fun setPackageSelected(packageName: String, selected: Boolean) {
val current = store.load()
val packages = current.selectedPackages.toMutableSet().apply {
if (selected) add(packageName) else remove(packageName)
}
store.save(current.copy(selectedPackages = packages))
// Do not reload: an unchecked unseen row remains visible until the next requested refresh.
}
private fun appView(row: ListedApp): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL
val checkbox = CheckBox(context).apply { isChecked = row.selected }
checkbox.setOnCheckedChangeListener { _, checked ->
val current = store.ruleFor(type)
val packages = current.selectedPackages.toMutableSet().apply {
if (checked) add(row.packageName) else remove(row.packageName)
}
store.save(type, current.copy(selectedPackages = packages))
// Deliberately do not reload: an unchecked unseen row must remain reachable.
}
addView(checkbox)
addView(LinearLayout(context).apply {
private fun showPerAppEventSettings(app: ListedApp) {
val eventTypes = listOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS)
val rules = LoggingRuleStore(requireContext())
val content = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
addView(TextView(context).apply { text = row.label; textSize = 16f; setTypeface(typeface, 1) })
addView(TextView(context).apply { text = row.packageName; textSize = 12f })
})
setPadding(dp(24), 0, dp(24), 0)
}
val useGlobal = SwitchMaterial(requireContext()).apply { text = "Use global" }
val eventToggles = eventTypes.associateWith { type ->
SwitchMaterial(requireContext()).apply {
text = type.label()
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply { marginStart = dp(24) }
}.also(content::addView)
}
content.addView(useGlobal, 0)
fun globalEvents() = eventTypes.associateWith { rules.ruleFor(it).enabled }
fun refresh() {
val useGlobalSetting = perAppEventSettings.usesGlobal(app.packageName)
useGlobal.setOnCheckedChangeListener(null)
useGlobal.isChecked = useGlobalSetting
eventToggles.forEach { (type, toggle) ->
toggle.setOnCheckedChangeListener(null)
toggle.isEnabled = !useGlobalSetting
toggle.isChecked = perAppEventSettings.isEnabled(
app.packageName,
type,
globalEvents().getValue(type),
)
toggle.setOnCheckedChangeListener { _, enabled ->
perAppEventSettings.setEnabled(app.packageName, type, enabled)
}
}
useGlobal.setOnCheckedChangeListener { _, enabled ->
if (enabled) perAppEventSettings.useGlobal(app.packageName)
else perAppEventSettings.startUsingOverride(app.packageName, globalEvents())
appAdapter.updateEventOverride(app.packageName, !enabled)
refresh()
}
}
refresh()
MaterialAlertDialogBuilder(requireContext())
.setTitle("When logging, only log")
.setView(content)
.setPositiveButton("Close", null)
.show()
}
private fun separator() = View(requireContext()).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { setMargins(0, 12, 0, 12) }
setBackgroundColor(0x33000000)
private fun LoggingType.label() = when (this) {
LoggingType.APPEARING -> "Appearing"
LoggingType.DISAPPEARING -> "Disappearing"
LoggingType.EDITS -> "Edits"
else -> error("Only event types are shown in the per-app dialog")
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private class AppListAdapter(
private val onSelectedChanged: (String, Boolean) -> Unit,
private val onEventFilterClicked: (ListedApp) -> Unit,
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var items: List<AppListItem> = emptyList()
fun submit(newItems: List<AppListItem>) {
items = newItems
notifyDataSetChanged()
}
override fun getItemViewType(position: Int): Int = when (items[position]) {
is AppListItem.App -> APP
is AppListItem.SectionTitle -> SECTION_TITLE
AppListItem.Separator -> SEPARATOR
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = if (viewType == APP) {
val row = LinearLayout(parent.context).apply {
orientation = LinearLayout.HORIZONTAL
setPadding(0, dp(parent, 4), 0, dp(parent, 4))
}
val checkbox = CheckBox(parent.context)
val filter = ImageButton(parent.context).apply {
setImageResource(R.drawable.ic_filter_list)
contentDescription = "Edit logging events"
layoutParams = LinearLayout.LayoutParams(dp(parent, 48), dp(parent, 48))
setPadding(dp(parent, 12), dp(parent, 12), dp(parent, 12), dp(parent, 12))
}
val textColumn = LinearLayout(parent.context).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val label = TextView(parent.context).apply { textSize = 16f; setTypeface(typeface, android.graphics.Typeface.BOLD) }
val packageName = TextView(parent.context).apply { textSize = 12f }
textColumn.addView(label)
textColumn.addView(packageName)
row.addView(checkbox)
row.addView(filter)
row.addView(textColumn)
AppHolder(row, checkbox, filter, label, packageName)
} else if (viewType == SECTION_TITLE) {
SectionTitleHolder(TextView(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(
RecyclerView.LayoutParams.MATCH_PARENT,
RecyclerView.LayoutParams.WRAP_CONTENT,
).apply { setMargins(0, dp(parent, 8), 0, dp(parent, 4)) }
setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, 12f)
setTypeface(typeface, android.graphics.Typeface.BOLD)
})
} else {
val divider = View(parent.context).apply {
layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, dp(parent, 1)).apply {
setMargins(0, dp(parent, 12), 0, dp(parent, 12))
}
setBackgroundColor(ContextCompat.getColor(parent.context, android.R.color.darker_gray))
}
SeparatorHolder(divider)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = items[position]
if (holder is AppHolder && item is AppListItem.App) {
val app = item.value
holder.label.text = app.label
holder.packageName.text = app.packageName
holder.checkbox.setOnCheckedChangeListener(null)
holder.checkbox.isChecked = app.selected
holder.checkbox.setOnCheckedChangeListener { _, checked ->
onSelectedChanged(app.packageName, checked)
items = items.map { current ->
if (current is AppListItem.App && current.value.packageName == app.packageName) {
AppListItem.App(current.value.copy(selected = checked))
} else current
}
}
holder.filter.contentDescription = "Edit logging events for ${app.label}"
val colors = if (app.hasEventOverride) {
R.color.app_filter_override_background to R.color.app_filter_override_icon
} else {
R.color.app_filter_inherited_background to R.color.app_filter_inherited_icon
}
holder.filter.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(holder.filter.context, colors.first))
holder.filter.imageTintList = ColorStateList.valueOf(ContextCompat.getColor(holder.filter.context, colors.second))
holder.filter.setOnClickListener { onEventFilterClicked(app) }
}
if (holder is SectionTitleHolder && item is AppListItem.SectionTitle) {
holder.text.text = item.value
}
}
override fun getItemCount(): Int = items.size
fun updateEventOverride(packageName: String, hasEventOverride: Boolean) {
val index = items.indexOfFirst { it is AppListItem.App && it.value.packageName == packageName }
if (index < 0) return
val app = (items[index] as AppListItem.App).value
items = items.toMutableList().also { it[index] = AppListItem.App(app.copy(hasEventOverride = hasEventOverride)) }
notifyItemChanged(index)
}
private class AppHolder(
view: View,
val checkbox: CheckBox,
val filter: ImageButton,
val label: TextView,
val packageName: TextView,
) : RecyclerView.ViewHolder(view)
private class SeparatorHolder(view: View) : RecyclerView.ViewHolder(view)
private class SectionTitleHolder(val text: TextView) : RecyclerView.ViewHolder(text)
private companion object {
const val APP = 0
const val SEPARATOR = 1
const val SECTION_TITLE = 2
fun dp(parent: ViewGroup, value: Int): Int = (value * parent.resources.displayMetrics.density).toInt()
}
}
companion object {
private const val ARG_TITLE = "title"
private const val ARG_TYPE = "type"
fun newInstance(@StringRes title: Int, type: LoggingType) = EventSettingsFragment().apply {
arguments = Bundle().apply { putInt(ARG_TITLE, title); putSerializable(ARG_TYPE, type) }
}
fun newInstance() = FilterAppsFragment()
}
}
@@ -0,0 +1,50 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.databinding.FragmentFilterLoggingBinding
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
class FilterLoggingFragment : Fragment(R.layout.fragment_filter_logging) {
private var binding: FragmentFilterLoggingBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentFilterLoggingBinding.bind(view)
val rules = LoggingRuleStore(requireContext())
addToggles(binding!!.eventToggles, listOf(LoggingType.APPEARING, LoggingType.DISAPPEARING, LoggingType.EDITS), rules)
addToggles(binding!!.contentToggles, listOf(LoggingType.TEXT_CONTENT, LoggingType.IMAGE_CONTENT), rules)
val capture = CaptureSettingsStore(requireContext())
binding!!.groupSummaries.isChecked = capture.logGroupSummaries
binding!!.groupSummaries.setOnCheckedChangeListener { _, checked -> capture.logGroupSummaries = checked }
binding!!.routineUpdates.isChecked = rules.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates
binding!!.routineUpdates.setOnCheckedChangeListener { _, checked -> rules.save(LoggingType.EDITS, rules.ruleFor(LoggingType.EDITS).copy(ignoreRoutineUpdates = checked)) }
}
private fun addToggles(
container: android.widget.LinearLayout,
types: List<LoggingType>,
rules: LoggingRuleStore,
) = types.forEach { type ->
container.addView(SwitchMaterial(requireContext()).apply {
text = type.label()
isChecked = rules.ruleFor(type).enabled
setOnCheckedChangeListener { _, checked ->
rules.save(type, rules.ruleFor(type).copy(enabled = checked))
}
})
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun LoggingType.label() = when (this) {
LoggingType.APPEARING -> "Appearing"
LoggingType.DISAPPEARING -> "Disappearing"
LoggingType.EDITS -> "Edits"
LoggingType.TEXT_CONTENT -> "Text content"
LoggingType.IMAGE_CONTENT -> "Image content"
}
}
@@ -0,0 +1,125 @@
package se.ajpanton.notificationlog
import android.app.Dialog
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Bundle
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.core.os.bundleOf
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() {
private lateinit var root: FrameLayout
private lateinit var image: ZoomImageView
private lateinit var progress: ProgressBar
private var controlsVisible = false
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
root = FrameLayout(requireContext()).apply { setBackgroundColor(Color.BLACK) }
image = ZoomImageView(requireContext()).apply {
visibility = View.GONE
onSingleTap = ::toggleSystemControls
}
progress = ProgressBar(requireContext()).apply { isIndeterminate = true }
root.addView(image, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
root.addView(progress, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER))
return Dialog(requireContext()).apply {
requestWindowFeature(Window.FEATURE_NO_TITLE)
setContentView(root)
}
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
WindowCompat.setDecorFitsSystemWindows(this, false)
attributes = attributes.apply {
layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
addFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
hideSystemControls()
loadImage(requireArguments().getString(ARGUMENT_IMAGE_ID)!!)
}
private fun loadImage(imageId: String) {
val targetWidth = resources.displayMetrics.widthPixels
val targetHeight = resources.displayMetrics.heightPixels
val appContext = requireContext().applicationContext
lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) { runCatching {
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
decodeForViewer(bytes, targetWidth, targetHeight)
}
}.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
}
}
}
private fun decodeForViewer(bytes: ByteArray, targetWidth: Int, targetHeight: Int): Bitmap? {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
var sampleSize = 1
while (bounds.outWidth / sampleSize > targetWidth * MAX_SCREEN_SCALE ||
bounds.outHeight / sampleSize > targetHeight * MAX_SCREEN_SCALE
) sampleSize *= 2
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sampleSize })
}
private fun toggleSystemControls() {
if (controlsVisible) hideSystemControls() else showSystemControls()
}
private fun hideSystemControls() {
val window = dialog?.window ?: return
WindowInsetsControllerCompat(window, root).apply {
systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
}
controlsVisible = false
}
private fun showSystemControls() {
val window = dialog?.window ?: return
WindowInsetsControllerCompat(window, root).show(WindowInsetsCompat.Type.systemBars())
controlsVisible = true
}
companion object {
private const val ARGUMENT_IMAGE_ID = "image_id"
private const val MAX_SCREEN_SCALE = 4
fun newInstance(imageId: String) = ImageViewerDialogFragment().apply {
arguments = bundleOf(ARGUMENT_IMAGE_ID to imageId)
}
}
}
@@ -0,0 +1,159 @@
package se.ajpanton.notificationlog
import android.content.ClipData
import android.os.Bundle
import android.view.View
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.databinding.FragmentLogDisplayBinding
import se.ajpanton.notificationlog.settings.DisplayEvent
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampClockFormat
import se.ajpanton.notificationlog.settings.TimestampDateFormat
import se.ajpanton.notificationlog.settings.TimestampZone
class LogDisplayFragment : Fragment(R.layout.fragment_log_display) {
private var binding: FragmentLogDisplayBinding? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding = FragmentLogDisplayBinding.bind(view)
val store = LogViewSettingsStore(requireContext())
var settings = store.load()
DisplayEvent.entries.forEach { event ->
binding!!.eventToggles.addView(SwitchMaterial(requireContext()).apply {
text = event.name.lowercase().replaceFirstChar(Char::uppercase)
isChecked = event in settings.visibleEvents
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleEvents = settings.visibleEvents.toMutableSet().apply {
if (checked) add(event) else remove(event)
},
)
store.save(settings)
}
})
}
settings.order.forEach { field ->
val fieldGroup = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
tag = field
}
val toggle = SwitchMaterial(requireContext()).apply {
text = field.label()
isChecked = field in settings.visibleFields
setOnLongClickListener {
startDragAndDrop(
ClipData.newPlainText("field", field.name),
View.DragShadowBuilder(fieldGroup),
fieldGroup,
0,
)
true
}
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
},
)
store.save(settings)
}
}
fieldGroup.addView(toggle)
if (field == LogField.TIMESTAMP) {
val controls = timestampControls({ settings }) { updated ->
settings = updated
store.save(settings)
}
fieldGroup.addView(controls)
setEnabledRecursively(controls, toggle.isChecked)
toggle.setOnCheckedChangeListener { _, checked ->
settings = settings.copy(
visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
},
)
store.save(settings)
setEnabledRecursively(controls, checked)
}
}
binding!!.fieldToggles.addView(fieldGroup)
}
binding!!.fieldToggles.setOnDragListener { _, event ->
if (event.action != android.view.DragEvent.ACTION_DROP) return@setOnDragListener true
val source = event.localState as? View ?: return@setOnDragListener false
val box = binding!!.fieldToggles
val index = (0 until box.childCount).firstOrNull { event.y < box.getChildAt(it).bottom } ?: box.childCount
box.removeView(source)
box.addView(source, index.coerceAtMost(box.childCount))
settings = settings.copy(order = (0 until box.childCount).map { box.getChildAt(it).tag as LogField })
store.save(settings)
true
}
}
override fun onDestroyView() { binding = null; super.onDestroyView() }
private fun timestampControls(
currentSettings: () -> LogViewSettings,
saveSettings: (LogViewSettings) -> Unit,
): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(TIMESTAMP_CONTROLS_INDENT_DP), 0, 0, 0)
addDropdown("Timestamp timezone", listOf("Local timezone when event happened", "Current local timezone", "UTC"), TimestampZone.entries.indexOf(currentSettings().timestampZone)) { position ->
val updated = currentSettings().copy(timestampZone = TimestampZone.entries[position])
saveSettings(updated)
}
addDropdown("Date format", listOf("System default", "YYYY-MM-DD", "DD-MM-YYYY", "MM-DD-YYYY"), TimestampDateFormat.entries.indexOf(currentSettings().timestampDateFormat)) { position ->
val updated = currentSettings().copy(timestampDateFormat = TimestampDateFormat.entries[position])
saveSettings(updated)
}
addDropdown("Clock format", listOf("System default", "24-hour clock", "12-hour clock"), TimestampClockFormat.entries.indexOf(currentSettings().timestampClockFormat)) { position ->
val updated = currentSettings().copy(timestampClockFormat = TimestampClockFormat.entries[position])
saveSettings(updated)
}
}
private fun LinearLayout.addDropdown(label: String, values: List<String>, selected: Int, onSelected: (Int) -> Unit) {
addView(TextView(context).apply { text = label })
addView(Spinner(context, Spinner.MODE_DROPDOWN).apply {
setPopupBackgroundDrawable(context.getDrawable(R.drawable.dropdown_popup_background))
adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, values)
setSelection(selected)
onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) = onSelected(position)
}
})
}
private fun setEnabledRecursively(view: View, enabled: Boolean) {
view.isEnabled = enabled
view.alpha = if (enabled) 1f else DISABLED_TIMESTAMP_CONTROLS_ALPHA
if (view is android.view.ViewGroup) {
(0 until view.childCount).forEach { setEnabledRecursively(view.getChildAt(it), enabled) }
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private companion object {
const val TIMESTAMP_CONTROLS_INDENT_DP = 24
const val DISABLED_TIMESTAMP_CONTROLS_ALPHA = 0.5f
}
private fun LogField.label() = when (this) {
LogField.TIMESTAMP -> "Timestamp"
LogField.APP_NAME -> "App name"
LogField.PACKAGE_NAME -> "Package name"
LogField.ACTION -> "What the app did"
LogField.CONTENTS -> "Contents of the notification"
}
}
@@ -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
@@ -17,28 +18,33 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.forEach
import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.commit
import com.google.android.material.navigation.NavigationView
import se.ajpanton.notificationlog.databinding.ActivityMainBinding
import se.ajpanton.notificationlog.settings.LoggingType
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.enabled)
val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES
WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode
@@ -84,12 +90,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
override fun onResume() {
super.onResume()
if (needsUnlock && AppLockStore(this).enabled) requestUnlock()
if (needsUnlock && appLockStore.enabled) {
showLockedOverlay(true)
requestUnlock()
} else {
showLockedOverlay(false)
}
}
override fun onStop() {
super.onStop()
if (!isChangingConfigurations) needsUnlock = true
if (!isChangingConfigurations) {
needsUnlock = true
if (appLockStore.enabled) showLockedOverlay(true)
}
}
private fun requestUnlock() {
@@ -98,15 +112,31 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
.build()
.authenticate(CancellationSignal(), mainExecutor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) { needsUnlock = false }
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult?) {
needsUnlock = false
showLockedOverlay(false)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { finishAndRemoveTask() }
})
}
private fun showLockedOverlay(visible: Boolean) {
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 {
if (item.itemId == R.id.nav_settings_header) {
return false
}
navigateTo(item.itemId)
if (!permanentSidebar) {
binding.drawerLayout.closeDrawer(GravityCompat.START)
@@ -124,13 +154,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
when {
page == Page.VIEW_LOGS -> ViewLogsFragment()
page == Page.SETTINGS -> SettingsFragment()
page.loggingType != null -> EventSettingsFragment.newInstance(page.titleRes, page.loggingType)
page == Page.LOG_DISPLAY -> LogDisplayFragment()
page == Page.FILTER_LOGGING -> FilterLoggingFragment()
page == Page.FILTER_APPS -> FilterAppsFragment.newInstance()
else -> PageFragment.newInstance(page.titleRes)
},
)
runOnCommit(::applyPageTitleVisibility)
}
title = getString(page.titleRes)
invalidateOptionsMenu()
}
private fun setupNavigationView(navigationView: NavigationView) {
@@ -147,14 +180,9 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun syncNavigationSelection(navigationView: NavigationView, itemId: Int) {
val menu = navigationView.menu ?: return
for (index in 0 until menu.size()) {
val item = menu.getItem(index)
if (item.itemId == R.id.nav_settings_header) {
item.isCheckable = false
} else {
item.isCheckable = true
item.isChecked = item.itemId == itemId
}
menu.forEach { item ->
item.isCheckable = true
item.isChecked = item.itemId == itemId
}
navigationView.invalidate()
}
@@ -263,7 +291,11 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
}
}
private fun countVisibleMenuRows(menu: Menu): Int = (0 until menu.size()).count { menu.getItem(it).isVisible }
private fun countVisibleMenuRows(menu: Menu): Int {
var visible = 0
menu.forEach { if (it.isVisible) visible++ }
return visible
}
private fun applyPageTitleVisibility() {
(supportFragmentManager.findFragmentById(R.id.content_frame) as? PageFragment)
@@ -275,18 +307,47 @@ 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,
val loggingType: LoggingType? = null,
) {
VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs),
SETTINGS(R.id.nav_settings, R.string.page_settings),
APPEARING(R.id.nav_appearing, R.string.page_appearing, LoggingType.APPEARING),
DISAPPEARING(R.id.nav_disappearing, R.string.page_disappearing, LoggingType.DISAPPEARING),
TEXT_CONTENT(R.id.nav_text_content, R.string.page_text_content, LoggingType.TEXT_CONTENT),
IMAGE_CONTENT(R.id.nav_image_content, R.string.page_image_content, LoggingType.IMAGE_CONTENT),
EDITS(R.id.nav_edits, R.string.page_edits, LoggingType.EDITS),
LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display),
FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
;
companion object {
@@ -296,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
}
}
@@ -0,0 +1,41 @@
package se.ajpanton.notificationlog
import android.app.Application
import android.content.pm.PackageManager
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.NotificationListenerComponentController
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
class NotificationLogApplication : Application() {
override fun onCreate() {
super.onCreate()
synchronizeListenerComponent()
Thread(::removeStaleAppStorage, "notification-log-storage-cleanup").start()
}
/**
* Clearing app data resets rule preferences but deliberately does not reset
* PackageManager's enabled/disabled state for the listener component.
* Reconcile it before the UI starts so fresh default rules can bind again.
*/
private fun synchronizeListenerComponent() {
val rules = LoggingRuleStore(this)
NotificationListenerComponentController.update(
this,
LoggingType.entries.associateWith(rules::ruleFor),
PerAppEventSettingsStore(this).hasEnabledEventOverride(),
)
}
private fun removeStaleAppStorage() {
val installedPackages = packageManager.getInstalledApplications(
PackageManager.ApplicationInfoFlags.of(0),
).mapTo(mutableSetOf()) { it.packageName }
AppFilterSettingsStore(this).removeUninstalledPackages(installedPackages)
PerAppEventSettingsStore(this).removeUninstalledPackages(installedPackages)
EncryptedNotificationLogStore(this).removeOrphanedImages()
}
}
@@ -3,6 +3,7 @@ package se.ajpanton.notificationlog
import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import androidx.core.graphics.withClip
import androidx.drawerlayout.widget.DrawerLayout
/**
@@ -33,9 +34,8 @@ class SafeInsetDrawerLayout @JvmOverloads constructor(
super.dispatchDraw(canvas)
return
}
val saveCount = canvas.save()
canvas.clipRect(safeInsetLeft, 0, (width - safeInsetRight).coerceAtLeast(safeInsetLeft), height)
super.dispatchDraw(canvas)
canvas.restoreToCount(saveCount)
canvas.withClip(safeInsetLeft, 0, (width - safeInsetRight).coerceAtLeast(safeInsetLeft), height) {
super.dispatchDraw(this)
}
}
}
@@ -1,28 +1,32 @@
package se.ajpanton.notificationlog
import android.os.Bundle
import android.content.ClipData
import android.net.Uri
import android.content.ComponentName
import android.content.Intent
import android.provider.Settings
import android.view.View
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore
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
import kotlinx.coroutines.withContext
class SettingsFragment : Fragment(R.layout.fragment_settings) {
private var binding: FragmentSettingsBinding? = null
private var selectedCopyTargets = emptySet<LoggingType>()
private var pendingExport: ExportFormat? = null
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
uri?.let(::writeExport)
@@ -30,147 +34,78 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding = FragmentSettingsBinding.bind(view)
val store = LogViewSettingsStore(requireContext())
var settings = store.load()
settings.order.forEach { field ->
binding!!.fieldToggles.addView(SwitchMaterial(requireContext()).apply {
text = when (field) {
LogField.TIMESTAMP -> "Timestamp"; LogField.APP_NAME -> "App name"
LogField.PACKAGE_NAME -> "Package name"; LogField.ACTION -> "What the app did"
LogField.CONTENTS -> "Contents of the notification"
}
isChecked = field in settings.visibleFields
tag = field
setOnLongClickListener {
startDragAndDrop(ClipData.newPlainText("field", field.name), View.DragShadowBuilder(this), this, 0)
true
}
setOnCheckedChangeListener { _, checked ->
settings = settings.copy(visibleFields = settings.visibleFields.toMutableSet().apply {
if (checked) add(field) else remove(field)
})
store.save(settings)
}
})
}
binding!!.fieldToggles.setOnDragListener { _, event ->
if (event.action != android.view.DragEvent.ACTION_DROP) return@setOnDragListener true
val source = event.localState as? View ?: return@setOnDragListener false
val container = binding!!.fieldToggles
val index = (0 until container.childCount).firstOrNull { event.y < container.getChildAt(it).bottom } ?: container.childCount
container.removeView(source); container.addView(source, index.coerceAtMost(container.childCount))
settings = settings.copy(order = (0 until container.childCount).map { container.getChildAt(it).tag as LogField })
store.save(settings)
true
}
val labels = listOf("Current local timezone", "UTC", "Local timezone when event happened")
binding!!.timezone.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.timezone.setSelection(TimestampZone.entries.indexOf(settings.timestampZone))
binding!!.timezone.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
settings = settings.copy(timestampZone = TimestampZone.entries[position]); store.save(settings)
}
}
setupCopyControls()
setupEventMasterToggles()
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
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() }
override fun onResume() {
super.onResume()
refreshEventMasterToggles()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
}
private fun setupEventMasterToggles() {
val container = binding!!.eventMasterToggles
LoggingType.entries.forEach { type ->
container.addView(SwitchMaterial(requireContext()).apply {
text = "${type.label()} logging"
tag = type
setOnCheckedChangeListener { _, enabled ->
LoggingRuleStore(requireContext()).save(type, LoggingRuleStore(requireContext()).ruleFor(type).copy(enabled = enabled))
}
})
}
refreshEventMasterToggles()
}
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun refreshEventMasterToggles() {
val store = LoggingRuleStore(requireContext())
val container = binding?.eventMasterToggles ?: return
for (index in 0 until container.childCount) {
val toggle = container.getChildAt(index) as SwitchMaterial
toggle.setOnCheckedChangeListener(null)
toggle.isChecked = store.ruleFor(toggle.tag as LoggingType).enabled
toggle.setOnCheckedChangeListener { _, enabled ->
val type = toggle.tag as LoggingType
store.save(type, store.ruleFor(type).copy(enabled = enabled))
private fun bindStorageLimits() {
val store = StorageLimitsStore(requireContext())
val limits = store.load()
binding!!.logLimitMib.setText((limits.logBytes / StorageLimitsStore.MEBIBYTE).toString())
binding!!.imageLimitMib.setText((limits.imageBytes / StorageLimitsStore.MEBIBYTE).toString())
binding!!.saveStorageLimits.setOnClickListener {
val logLimit = binding!!.logLimitMib.text.toString().toLongOrNull()
val imageLimit = binding!!.imageLimitMib.text.toString().toLongOrNull()
if (logLimit == null || imageLimit == null ||
logLimit !in 1..MAXIMUM_LIMIT_MIB || imageLimit !in 1..MAXIMUM_LIMIT_MIB
) {
binding!!.logLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB"
binding!!.imageLimitMib.error = "Enter 1 to $MAXIMUM_LIMIT_MIB MiB"
return@setOnClickListener
}
store.save(StorageLimits(logLimit * StorageLimitsStore.MEBIBYTE, imageLimit * StorageLimitsStore.MEBIBYTE))
val appContext = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
EncryptedNotificationLogStore(appContext).enforceLimits()
}
}
}
private fun setupCopyControls() {
val labels = listOf("From") + LoggingType.entries.map { it.label() }
binding!!.copyFrom.adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, labels)
binding!!.copyFrom.onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
override fun onNothingSelected(parent: android.widget.AdapterView<*>?) = Unit
override fun onItemSelected(parent: android.widget.AdapterView<*>?, view: View?, position: Int, id: Long) {
selectedCopyTargets = emptySet()
binding!!.copyTo.isEnabled = position > 0
updateCopyButton()
}
}
binding!!.copyTo.setOnClickListener { chooseCopyTargets(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1]) }
binding!!.copyEventSettings.setOnClickListener {
LoggingRuleStore(requireContext()).copy(LoggingType.entries[binding!!.copyFrom.selectedItemPosition - 1], selectedCopyTargets)
}
}
private fun chooseCopyTargets(source: LoggingType) {
val targets = LoggingType.entries.filterNot { it == source }
val checked = BooleanArray(targets.size) { targets[it] in selectedCopyTargets }
private fun confirmClearLogs() {
val filterStore = LogAppFilterStore(requireContext())
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Copy ${source.label()} settings to")
.setMultiChoiceItems(targets.map { it.label() }.toTypedArray(), checked) { _, index, selected -> checked[index] = selected }
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setNeutralButton("All", null)
.setPositiveButton("Done") { _, _ ->
selectedCopyTargets = targets.filterIndexed { index, _ -> checked[index] }.toSet()
updateCopyButton()
.setPositiveButton("Clear") { _, _ ->
clearLogs()
}
.create()
dialog.setOnShowListener {
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener {
val selectAll = checked.any { !it }
checked.indices.forEach { index ->
checked[index] = selectAll
dialog.listView.setItemChecked(index, selectAll)
}
if (filterStore.selectedPackages().isNotEmpty()) {
dialog.setNeutralButton("Clear and reset filter") { _, _ ->
filterStore.clear()
(activity as? MainActivity)?.updateLogFilterAction()
clearLogs()
}
}
dialog.show()
}
private fun updateCopyButton() { binding?.copyEventSettings?.isEnabled = selectedCopyTargets.isNotEmpty() }
private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ -> EncryptedNotificationLogStore(requireContext()).clear() }
.show()
private fun clearLogs() {
val appContext = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
EncryptedNotificationLogStore(appContext).clear()
}
}
private fun chooseExportFormat() {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
.setItems(arrayOf("CSV (no images)", "Formatted text (no images)", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
}
@@ -189,36 +124,94 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
private fun writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
Thread {
val entries = EncryptedNotificationLogStore(context).readAll()
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 -> output.write(LogExporter.csv(entries, settings).encodeToByteArray())
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip ->
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
val imageStore = EncryptedImageStore(context)
val imageEntries = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
val exportedImages = imageEntries.mapNotNull { entry ->
imageStore.read(entry.imageId!!)?.let { entry.imageId to it }
}.toMap()
zip.write(LogExporter.html(entries, settings) { entry ->
entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" }
}.encodeToByteArray())
zip.closeEntry()
exportedImages.forEach { (imageId, image) ->
zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zip.write(image)
zip.closeEntry()
}
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)
}
}
}
}
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(
writer: java.io.BufferedWriter,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
context: android.content.Context,
) = writer.use {
it.write(LogExporter.csvHeader(settings))
logStore.forEachNewest { entry ->
it.newLine()
it.write(LogExporter.csvRow(entry, settings, context))
}
}
private fun writeFormatted(
writer: java.io.BufferedWriter,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
context: android.content.Context,
) = writer.use {
val headers = LogExporter.headers(settings)
val widths = headers.map { header -> header.length }.toMutableList()
logStore.forEachNewest { entry ->
LogExporter.values(entry, settings, context).forEachIndexed { index, value ->
widths[index] = maxOf(widths[index], value.length)
}
}
it.write(LogExporter.formattedRow(headers, widths))
logStore.forEachNewest { entry ->
it.newLine()
it.write(LogExporter.formattedRow(LogExporter.values(entry, settings, context), widths))
}
}
private fun writeHtmlZip(
zip: ZipOutputStream,
logStore: EncryptedNotificationLogStore,
settings: se.ajpanton.notificationlog.settings.LogViewSettings,
context: android.content.Context,
) = zip.use { zipOutput ->
val imageStore = EncryptedImageStore(context)
zipOutput.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
zipOutput.write(LogExporter.htmlStart(settings).encodeToByteArray())
logStore.forEachNewest { entry ->
val imagePath = entry.imageId
?.takeIf { LogField.CONTENTS in settings.visibleFields && imageStore.exists(it) }
?.let { imageId -> "images/$imageId.png" }
zipOutput.write(LogExporter.htmlRow(entry, settings, imagePath, context).encodeToByteArray())
}
zipOutput.write(LogExporter.htmlEnd().encodeToByteArray())
zipOutput.closeEntry()
if (LogField.CONTENTS in settings.visibleFields) {
logStore.forEachNewest { entry ->
entry.imageId?.let { imageId ->
imageStore.read(imageId)?.let { image ->
zipOutput.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zipOutput.write(image)
zipOutput.closeEntry()
}
}
}
}.start()
}
}
private fun LoggingType.label() = name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
private companion object {
const val MAXIMUM_LIMIT_MIB = 1024L * 1024L
}
}
@@ -1,53 +1,83 @@
package se.ajpanton.notificationlog
import android.content.ComponentName
import android.content.Intent
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.graphics.Color
import android.graphics.BitmapFactory
import android.net.Uri
import android.graphics.Paint
import android.os.Bundle
import android.provider.Settings
import android.content.ComponentName
import android.app.NotificationManager
import android.text.TextPaint
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.NewestLogCursor
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationlog.export.LogExporter
import se.ajpanton.notificationlog.model.NotificationAction
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.TimestampZone
import java.text.DateFormat
import java.util.Date
import java.util.zip.ZipOutputStream
import se.ajpanton.notificationlog.settings.LogAppFilterStore
import se.ajpanton.notificationlog.settings.TimestampFormatter
import se.ajpanton.notificationlog.settings.DisplayEvent
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import androidx.fragment.app.Fragment
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.DiffUtil
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null
private var pendingExport: ExportFormat? = null
private val expandedIds = mutableSetOf<String>()
private val createDocument = registerForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream"),
) { uri -> uri?.let(::writeExport) }
private val rows = mutableListOf<LogRow>()
private var nextCursor: NewestLogCursor? = null
private var loading = false
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)
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
binding!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
loadLogs()
appFilterStore = LogAppFilterStore(requireContext())
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
binding!!.logList.layoutManager = LinearLayoutManager(requireContext())
binding!!.logList.adapter = logAdapter
binding!!.logList.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
val scrollY = recyclerView.computeVerticalScrollOffset()
binding?.scrollToTop?.visibility = if (scrollY > dp(SCROLL_TO_TOP_THRESHOLD_DP)) View.VISIBLE else View.GONE
if (!noMoreRows && !loading && !recyclerView.canScrollVertically(1)) {
loadLogs(reset = false)
}
}
})
binding!!.scrollToTop.setOnClickListener {
binding?.logList?.smoothScrollToPosition(0)
}
loadLogs(reset = true)
}
override fun onResume() {
super.onResume()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
loadLogs(reset = true)
}
override fun onDestroyView() {
@@ -55,99 +85,275 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
super.onDestroyView()
}
private fun loadLogs() {
private fun loadLogs(reset: Boolean) {
if (loading && !reset) return
if (!reset && noMoreRows) return
val context = requireContext().applicationContext
Thread {
val imageStore = EncryptedImageStore(context)
val rows = EncryptedNotificationLogStore(context).readAll()
.sortedByDescending { it.recordedAtEpochMillis }
.map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) }
activity?.runOnUiThread { binding?.let { render(it, rows) } }
}.start()
if (reset) {
rows.clear()
nextCursor = null
noMoreRows = false
loadGeneration++
}
val generation = loadGeneration
loading = true
binding?.logsRefresh?.isRefreshing = true
viewLifecycleOwner.lifecycleScope.launch {
val page = withContext(Dispatchers.IO) {
EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE)
}
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, rows: List<LogRow>) {
view.logRows.removeAllViews()
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
private fun render(view: FragmentViewLogsBinding) {
val settings = LogViewSettingsStore(requireContext()).load()
rows.forEach { row -> view.logRows.addView(logRowView(row, settings)) }
val visibleRows = visibleRows(settings)
view.emptyView.visibility = if (visibleRows.isEmpty()) View.VISIBLE else View.GONE
view.emptyView.text = if (hasNotificationAccess()) {
if (appFilterStore.selectedPackages().isEmpty()) "No logs yet." else "No logs match the app filter."
} else {
"Notification access is disabled. Enable it in Settings."
}
logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings))
}
private fun logRowView(row: LogRow, settings: LogViewSettings): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
/** 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>()
appFilterStore.selectedApps().forEach { app ->
val name = app.name.takeUnless { it == app.packageName } ?: runCatching {
appContext.packageManager.getApplicationLabel(
appContext.packageManager.getApplicationInfo(app.packageName, 0),
).toString()
}.getOrDefault(app.packageName)
byPackage[app.packageName] = LoggedApp(name, app.packageName)
}
EncryptedNotificationLogStore(appContext).forEachNewest { entry ->
byPackage[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(apps.filter { it.packageName in selected }.map { app ->
LogAppFilterStore.App(app.name, app.packageName)
})
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
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = dp(12)
bottomMargin = 0
}
isClickable = true
isFocusable = true
fun renderExpanded(expanded: Boolean) {
removeAllViews()
if (!expanded) {
addView(TextView(context).apply {
text = values(row.entry, settings).joinToString(" · ")
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(8), 0, dp(8))
})
return
val metadata = metadataValues(row.entry, settings)
val contents = fieldValue(LogField.CONTENTS, row.entry, settings)
val hasContents = LogField.CONTENTS in settings.visibleFields && !contents.isNullOrEmpty()
val toggleExpanded = {
val collapsing = expanded
val collapseAnchor = if (collapsing) captureCollapseAnchor(this@apply) else null
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
renderExpanded(row.entry.id in expandedIds)
if (collapsing) {
val highlight = { highlightCollapsedRow(this@apply) }
if (collapseAnchor != null) {
restoreCollapseAnchor(collapseAnchor, highlight)
} else {
this@apply.doOnPreDraw { highlight() }
}
}
Unit
}
settings.order.filter { it in settings.visibleFields }.forEach { field ->
val value = fieldValue(field, row.entry, settings).takeUnless { field == LogField.CONTENTS && it == "[image]" }
if (!value.isNullOrEmpty()) {
addView(TextView(context).apply {
text = value
setLineSpacing(0f, 0.92f)
setPadding(0, dp(1), 0, dp(1))
})
val metadataCell = metadataCell(metadata, expanded, toggleExpanded, row.entry.id).apply {
layoutParams = LinearLayout.LayoutParams(
if (hasContents) metadataColumnWidth else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
).apply {
if (hasContents) marginEnd = dp(COLUMN_GAP_DP)
}
}
if (row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
addView(TextView(context).apply {
text = "Previous: ${row.entry.previousContents}"
setLineSpacing(0f, 0.92f)
setPadding(0, dp(1), 0, dp(1))
})
}
row.imageBytes?.let { bytes ->
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap ->
addView(ImageView(context).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
maxHeight = dp(240)
contentDescription = "Notification image"
setPadding(0, dp(4), 0, dp(4))
})
if (metadata.isNotEmpty()) addView(metadataCell)
if (hasContents) {
val contentsCell = contentsCell(row, contents!!, metadata.size.coerceAtLeast(1), expanded, toggleExpanded).apply {
layoutParams = LinearLayout.LayoutParams(
if (metadata.isNotEmpty()) 0 else LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
if (metadata.isNotEmpty()) 1f else 0f,
)
}
addView(contentsCell)
}
}
renderExpanded(row.entry.id in expandedIds)
setOnClickListener {
if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
renderExpanded(row.entry.id in expandedIds)
}
setOnLongClickListener {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete this log?")
.setMessage("This permanently removes this log and its copied image, if any.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ ->
Thread {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(row.entry.id)
activity?.runOnUiThread(::loadLogs)
}.start()
}
.show()
true
showDeleteDialog(row.entry.id)
}
}
private fun values(entry: NotificationLogEntry, settings: LogViewSettings): List<String> =
settings.order.filter { it in settings.visibleFields }.mapNotNull { fieldValue(it, entry, settings) }
private fun metadataCell(
values: List<MetadataValue>,
expanded: Boolean,
onToggleExpanded: () -> Unit,
entryId: String,
): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP), dp(CELL_HORIZONTAL_PADDING_DP), dp(CELL_VERTICAL_PADDING_DP))
isClickable = true
isFocusable = true
values.forEach { value ->
addView(TextView(context).apply {
text = if (expanded && value.breakBeforePeriods) value.text.replace(".", "\u200B.") else value.text
maxLines = if (expanded) Int.MAX_VALUE else 1
ellipsize = if (expanded) null else TextUtils.TruncateAt.END
if (expanded) {
breakStrategy = android.graphics.text.LineBreaker.BREAK_STRATEGY_HIGH_QUALITY
hyphenationFrequency = android.text.Layout.HYPHENATION_FREQUENCY_NONE
}
textSize = value.textSize
if (value.indented) setPadding(dp(8), 0, 0, 0)
setLineSpacing(0f, 1f)
if (value.bold) setTypeface(typeface, android.graphics.Typeface.BOLD)
})
}
setOnClickListener { onToggleExpanded() }
setOnLongClickListener { showDeleteDialog(entryId) }
}
private fun contentsCell(
row: LogRow,
contents: String,
collapsedLines: Int,
expanded: Boolean,
onToggleExpanded: () -> Unit,
): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, dp(CELL_VERTICAL_PADDING_DP), 0, dp(CELL_VERTICAL_PADDING_DP))
isClickable = true
isFocusable = true
val message = TextView(context).apply {
text = contents
maxLines = if (expanded) Int.MAX_VALUE else collapsedLines
ellipsize = if (expanded) null else TextUtils.TruncateAt.END
setLineSpacing(0f, 1f)
}
addView(message)
if (expanded && row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
addView(previousDivider())
addView(TextView(context).apply {
text = row.entry.previousContents
setLineSpacing(0f, 1f)
})
}
if (expanded && row.entry.imageId != null) {
val imageId = row.entry.imageId
val appContext = requireContext().applicationContext
val contentsContainer = this
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
EncryptedImageStore(appContext).read(imageId)?.let { bytes ->
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
}
if (contentsContainer.isAttachedToWindow && bitmap != null) {
contentsContainer.addView(ImageView(contentsContainer.context).apply {
setImageBitmap(bitmap)
adjustViewBounds = true
maxHeight = dp(240)
contentDescription = "Notification image"
setPadding(0, dp(4), 0, 0)
setOnClickListener {
if (isAdded) {
ImageViewerDialogFragment.newInstance(imageId).show(parentFragmentManager, IMAGE_VIEWER_TAG)
}
}
})
}
}
}
setOnClickListener { onToggleExpanded() }
setOnLongClickListener { showDeleteDialog(row.entry.id) }
}
private fun previousDivider(): LinearLayout = LinearLayout(requireContext()).apply {
gravity = Gravity.CENTER_VERTICAL
setPadding(0, dp(8), 0, dp(4))
fun line() = View(context).apply {
setBackgroundColor(ContextCompat.getColor(context, R.color.log_row_separator))
layoutParams = LinearLayout.LayoutParams(0, dp(1), 1f)
}
addView(line())
addView(TextView(context).apply {
text = "Previous"
textSize = 12f
setTextColor(ContextCompat.getColor(context, R.color.on_surface))
setBackgroundColor(ContextCompat.getColor(context, R.color.window_background))
setPadding(dp(8), 0, dp(8), 0)
})
addView(line())
}
private fun metadataValues(entry: NotificationLogEntry, settings: LogViewSettings): List<MetadataValue> = buildList {
if (LogField.TIMESTAMP in settings.visibleFields) add(MetadataValue(timestamp(entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId), 12f, isTimestamp = true))
if (LogField.APP_NAME in settings.visibleFields) add(MetadataValue(entry.appName, 15f, indented = true, bold = true))
if (LogField.PACKAGE_NAME in settings.visibleFields) add(MetadataValue(entry.packageName, 12f, indented = true, breakBeforePeriods = true))
if (LogField.ACTION in settings.visibleFields) add(MetadataValue(actionLabel(entry.action), 14f, indented = true))
}
private fun fieldValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings): String? = when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId)
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> actionLabel(entry.action)
@@ -155,92 +361,223 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
}
private fun actionLabel(action: NotificationAction): String = when (action) {
NotificationAction.APP_CANCELLED -> "App cancelled notification"
NotificationAction.APP_CANCELLED_ALL -> "App cancelled all notifications"
NotificationAction.USER_DISMISSED -> "User dismissed notification"
NotificationAction.USER_DISMISSED_ALL -> "User dismissed all notifications"
NotificationAction.APPEARED -> "Appeared"
NotificationAction.ALREADY_ACTIVE -> "Already active"
NotificationAction.EDITED -> "Edited"
NotificationAction.APP_CANCELLED -> "App cancelled"
NotificationAction.APP_CANCELLED_ALL -> "App cancelled all"
NotificationAction.USER_DISMISSED -> "User dismissed"
NotificationAction.USER_DISMISSED_ALL -> "User dismissed all"
NotificationAction.USER_CLICKED -> "User opened"
else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) {
TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC")
TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(java.util.TimeZone::getTimeZone) ?: java.util.TimeZone.getDefault()
}
}.format(Date(value))
private fun eventFor(action: NotificationAction): DisplayEvent = when (action) {
NotificationAction.APPEARED, NotificationAction.ALREADY_ACTIVE -> DisplayEvent.APPEARING
NotificationAction.EDITED -> DisplayEvent.EDITS
else -> DisplayEvent.DISAPPEARING
}
private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
private fun hasNotificationAccess(): Boolean = requireContext()
.getSystemService(NotificationManager::class.java)
.isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ ->
EncryptedNotificationLogStore(requireContext()).clear()
expandedIds.clear()
loadLogs()
}
.show()
private fun metadataColumnWidth(availableWidth: Int, rows: List<LogRow>, settings: LogViewSettings): Int {
val values = rows.flatMap { metadataValues(it.entry, settings) }
val widestTimestamp = values.filter { it.isTimestamp }
.maxOfOrNull(::metadataTextWidth)
?: 0
val widestMetadata = values
.maxOfOrNull(::metadataTextWidth)
?: 0
val timestampMinimumWidth = maxOf(
dp(MINIMUM_METADATA_COLUMN_DP),
widestTimestamp + dp(CELL_HORIZONTAL_PADDING_DP * 2),
)
val preferredWidth = maxOf(timestampMinimumWidth, widestMetadata + dp(CELL_HORIZONTAL_PADDING_DP * 2))
if (availableWidth <= 0) return preferredWidth
val maximumWidth = maxOf(
timestampMinimumWidth,
(availableWidth * MAXIMUM_METADATA_COLUMN_FRACTION).toInt(),
)
return minOf(preferredWidth, maximumWidth)
}
private fun confirmExport() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Export unencrypted logs?")
.setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Continue") { _, _ -> chooseExportFormat() }
.show()
private fun metadataTextWidth(value: MetadataValue): Int = TextPaint(Paint.ANTI_ALIAS_FLAG).run {
textSize = sp(value.textSize)
typeface = if (value.bold) android.graphics.Typeface.DEFAULT_BOLD else android.graphics.Typeface.DEFAULT
measureText(value.text).toInt() + if (value.indented) dp(METADATA_INDENT_DP) else 0
}
private fun chooseExportFormat() {
MaterialAlertDialogBuilder(requireContext())
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which ->
pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}")
}
.show()
}
private fun writeExport(uri: Uri) {
val format = pendingExport ?: return
val context = requireContext().applicationContext
Thread {
val entries = EncryptedNotificationLogStore(context).readAll()
val settings = LogViewSettingsStore(context).load()
context.contentResolver.openOutputStream(uri)?.use { output ->
when (format) {
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray())
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray())
ExportFormat.HTML_ZIP -> writeHtmlExport(output, entries, settings, context)
}
}
}.start()
}
private fun writeHtmlExport(output: java.io.OutputStream, entries: List<NotificationLogEntry>, settings: LogViewSettings, context: android.content.Context) {
ZipOutputStream(output).use { zip ->
val imageStore = EncryptedImageStore(context)
val exportedImages = entries.filter { it.imageId != null && LogField.CONTENTS in settings.visibleFields }
.mapNotNull { entry -> imageStore.read(entry.imageId!!)?.let { entry.imageId to it } }
.toMap()
zip.putNextEntry(java.util.zip.ZipEntry("notification-log.html"))
zip.write(LogExporter.html(entries, settings) { entry ->
entry.imageId?.takeIf(exportedImages::containsKey)?.let { "images/$it.png" }
}.encodeToByteArray())
zip.closeEntry()
exportedImages.forEach { (imageId, image) ->
zip.putNextEntry(java.util.zip.ZipEntry("images/$imageId.png"))
zip.write(image)
zip.closeEntry()
}
private fun rowSeparator(): View = View(requireContext()).apply {
background = androidx.core.content.ContextCompat.getDrawable(context, R.drawable.log_row_separator)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1)).apply {
topMargin = dp(ROW_SEPARATOR_MARGIN_DP)
bottomMargin = dp(ROW_SEPARATOR_MARGIN_DP)
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private inner class LogAdapter : RecyclerView.Adapter<LogAdapter.Holder>() {
private var rows: List<LogRow> = emptyList()
private var settings = LogViewSettings()
private var metadataWidth = 0
private data class LogRow(val entry: NotificationLogEntry, val imageBytes: ByteArray?)
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") }
fun submit(rows: List<LogRow>, settings: LogViewSettings, metadataWidth: Int) {
val previous = this.rows
this.rows = rows
this.settings = settings
this.metadataWidth = metadataWidth
DiffUtil.calculateDiff(object : DiffUtil.Callback() {
override fun getOldListSize() = previous.size
override fun getNewListSize() = rows.size
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int) =
previous[oldItemPosition].entry.id == rows[newItemPosition].entry.id
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int) =
previous[oldItemPosition] == rows[newItemPosition]
}).dispatchUpdatesTo(this)
notifyItemRangeChanged(0, itemCount)
}
override fun onCreateViewHolder(parent: android.view.ViewGroup, viewType: Int): Holder {
val root = LinearLayout(parent.context).apply { orientation = LinearLayout.VERTICAL }
val content = LinearLayout(parent.context)
root.addView(content)
root.addView(rowSeparator())
return Holder(root, content)
}
override fun onBindViewHolder(holder: Holder, position: Int) {
holder.content.removeAllViews()
holder.content.addView(logRowView(rows[position], settings, metadataWidth))
holder.separator.visibility = if (position == itemCount - 1) View.GONE else View.VISIBLE
}
override fun getItemCount(): Int = rows.size
inner class Holder(val root: LinearLayout, val content: LinearLayout) : RecyclerView.ViewHolder(root) {
val separator: View get() = root.getChildAt(1)
}
}
private fun timestamp(value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String =
TimestampFormatter.format(requireContext(), value, settings, eventTimeZoneId)
private fun showDeleteDialog(entryId: String): Boolean {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete this log?")
.setMessage("This permanently removes this log and its copied image, if any.")
.setNegativeButton("Cancel", null)
.setPositiveButton("Delete") { _, _ ->
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) {
EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
}
loadLogs(reset = true)
}
}
.show()
return true
}
private fun captureCollapseAnchor(collapsingRow: View): CollapseAnchor? {
val currentBinding = binding ?: return null
val list = currentBinding.logList
val manager = list.layoutManager as? LinearLayoutManager ?: return null
val collapsingItem = list.findContainingItemView(collapsingRow) ?: return null
val collapsingPosition = list.getChildAdapterPosition(collapsingItem)
val topPosition = manager.findFirstVisibleItemPosition()
val bottomPosition = manager.findLastVisibleItemPosition()
val topRow = manager.findViewByPosition(topPosition)
val bottomRow = manager.findViewByPosition(bottomPosition)
return when {
topPosition == collapsingPosition && bottomPosition == collapsingPosition -> CollapseAnchor.CENTER_COLLAPSED_ROW(collapsingPosition)
topPosition == collapsingPosition && bottomRow != null ->
CollapseAnchor.KEEP_BOTTOM_EDGE(bottomPosition, bottomRow.bottom - list.height)
bottomPosition == collapsingPosition && topRow != null ->
CollapseAnchor.KEEP_TOP_EDGE(topPosition, topRow.top)
else -> null
}
}
private fun restoreCollapseAnchor(anchor: CollapseAnchor, afterRestore: () -> Unit) {
val currentBinding = binding ?: return
val list = currentBinding.logList
val manager = list.layoutManager as? LinearLayoutManager ?: return
list.doOnPreDraw {
if (binding !== currentBinding) return@doOnPreDraw
val row = manager.findViewByPosition(anchor.position) ?: return@doOnPreDraw
val delta = when (anchor) {
is CollapseAnchor.CENTER_COLLAPSED_ROW -> row.top + row.height / 2 - list.height / 2
is CollapseAnchor.KEEP_TOP_EDGE -> row.top - anchor.offset
is CollapseAnchor.KEEP_BOTTOM_EDGE -> row.bottom - list.height - anchor.offset
}
list.scrollBy(0, delta)
afterRestore()
}
}
private fun highlightCollapsedRow(row: View) {
if (!row.isAttachedToWindow) return
val baseColor = ContextCompat.getColor(row.context, R.color.primary)
val startColor = Color.argb(
COLLAPSE_HIGHLIGHT_ALPHA,
Color.red(baseColor),
Color.green(baseColor),
Color.blue(baseColor),
)
val endColor = Color.argb(0, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor))
row.setBackgroundColor(startColor)
ValueAnimator.ofObject(ArgbEvaluator(), startColor, endColor).apply {
duration = COLLAPSE_HIGHLIGHT_DURATION_MILLIS
addUpdateListener { row.setBackgroundColor(it.animatedValue as Int) }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
row.background = null
}
})
start()
}
}
private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
private fun sp(value: Float): Float = android.util.TypedValue.applyDimension(
android.util.TypedValue.COMPLEX_UNIT_SP,
value,
resources.displayMetrics,
)
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,
val indented: Boolean = false,
val bold: Boolean = false,
val breakBeforePeriods: Boolean = false,
val isTimestamp: Boolean = false,
)
private sealed interface CollapseAnchor {
val position: Int
data class CENTER_COLLAPSED_ROW(override val position: Int) : CollapseAnchor
data class KEEP_TOP_EDGE(override val position: Int, val offset: Int) : CollapseAnchor
data class KEEP_BOTTOM_EDGE(override val position: Int, val offset: Int) : CollapseAnchor
}
private companion object {
const val SCROLL_TO_TOP_THRESHOLD_DP = 120
const val LOAD_MORE_THRESHOLD_DP = 480
const val PAGE_SIZE = 80
const val IMAGE_VIEWER_TAG = "image-viewer"
const val MINIMUM_METADATA_COLUMN_DP = 120
const val CELL_HORIZONTAL_PADDING_DP = 8
const val CELL_VERTICAL_PADDING_DP = 6
const val COLUMN_GAP_DP = 6
const val ROW_SEPARATOR_MARGIN_DP = 4
const val METADATA_INDENT_DP = 8
const val MAXIMUM_METADATA_COLUMN_FRACTION = 0.45f
const val COLLAPSE_HIGHLIGHT_ALPHA = 52
const val COLLAPSE_HIGHLIGHT_DURATION_MILLIS = 450L
}
}
@@ -0,0 +1,164 @@
package se.ajpanton.notificationlog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.graphics.RectF
import android.util.AttributeSet
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import androidx.appcompat.widget.AppCompatImageView
/** A fit-to-screen image which supports pinch zoom and one-finger panning. */
class ZoomImageView @JvmOverloads constructor(
context: Context,
attributes: AttributeSet? = null,
) : AppCompatImageView(context, attributes) {
var onSingleTap: (() -> Unit)? = null
private val transform = Matrix()
private val bounds = RectF()
private var minimumScale = 1f
private var currentScale = 1f
private var lastX = 0f
private var lastY = 0f
private var dragging = false
private var lastFocusX = 0f
private var lastFocusY = 0f
private var pointerCount = 0
private var quickScaleGesture = false
private val scaleDetector = ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
lastFocusX = detector.focusX
lastFocusY = detector.focusY
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
// Focus movement while two fingers are down is two-finger panning.
// Quick scale has one pointer, so it remains pure zoom around its
// built-in double-tap anchor.
setScaleAround((currentScale * detector.scaleFactor).coerceIn(minimumScale, minimumScale * MAX_ZOOM), detector.focusX, detector.focusY)
if (pointerCount >= 2) {
transform.postTranslate(detector.focusX - lastFocusX, detector.focusY - lastFocusY)
}
lastFocusX = detector.focusX
lastFocusY = detector.focusY
constrain()
return true
}
})
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapConfirmed(event: MotionEvent): Boolean {
onSingleTap?.invoke()
return true
}
override fun onDoubleTap(event: MotionEvent): Boolean {
quickScaleGesture = true
return true
}
override fun onDoubleTapEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
quickScaleGesture = false
}
return true
}
})
init {
scaleType = ScaleType.MATRIX
scaleDetector.isQuickScaleEnabled = true
}
override fun setImageBitmap(bitmap: Bitmap?) {
super.setImageBitmap(bitmap)
post(::resetToFit)
}
override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
super.onSizeChanged(width, height, oldWidth, oldHeight)
resetToFit()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
pointerCount = event.pointerCount
scaleDetector.onTouchEvent(event)
gestureDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
dragging = false
}
MotionEvent.ACTION_MOVE -> if (event.pointerCount == 1 && !scaleDetector.isInProgress && !quickScaleGesture) {
val dx = event.x - lastX
val dy = event.y - lastY
if (dx != 0f || dy != 0f) {
dragging = true
transform.postTranslate(dx, dy)
constrain()
}
lastX = event.x
lastY = event.y
}
MotionEvent.ACTION_POINTER_UP -> {
val remainingPointer = if (event.actionIndex == 0) 1 else 0
lastX = event.getX(remainingPointer)
lastY = event.getY(remainingPointer)
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (dragging) performClick()
}
}
return true
}
override fun performClick(): Boolean = super.performClick()
private fun setScaleAround(scale: Float, pivotX: Float, pivotY: Float) {
transform.postScale(scale / currentScale, scale / currentScale, pivotX, pivotY)
currentScale = scale
}
private fun resetToFit() {
val drawable = drawable ?: return
if (width == 0 || height == 0 || drawable.intrinsicWidth <= 0 || drawable.intrinsicHeight <= 0) return
minimumScale = minOf(width.toFloat() / drawable.intrinsicWidth, height.toFloat() / drawable.intrinsicHeight)
currentScale = minimumScale
transform.reset()
transform.postScale(minimumScale, minimumScale)
transform.postTranslate(
(width - drawable.intrinsicWidth * minimumScale) / 2f,
(height - drawable.intrinsicHeight * minimumScale) / 2f,
)
imageMatrix = transform
}
private fun constrain() {
val drawable = drawable ?: return
bounds.set(0f, 0f, drawable.intrinsicWidth.toFloat(), drawable.intrinsicHeight.toFloat())
transform.mapRect(bounds)
val dx = when {
bounds.width() <= width -> width / 2f - bounds.centerX()
bounds.left > 0f -> -bounds.left
bounds.right < width -> width - bounds.right
else -> 0f
}
val dy = when {
bounds.height() <= height -> height / 2f - bounds.centerY()
bounds.top > 0f -> -bounds.top
bounds.bottom < height -> height - bounds.bottom
else -> 0f
}
transform.postTranslate(dx, dy)
imageMatrix = transform
}
private companion object {
const val MAX_ZOOM = 6f
}
}
@@ -0,0 +1,17 @@
package se.ajpanton.notificationlog.capture
import se.ajpanton.notificationlog.model.NotificationAction
/** Applies the global, Android-provided group-summary classification without inspecting text. */
object GroupSummaryPolicy {
fun shouldLog(snapshot: NotificationSnapshot, logGroupSummaries: Boolean): Boolean =
shouldLog(snapshot, action = null, logGroupSummaries)
fun shouldLog(
snapshot: NotificationSnapshot,
action: NotificationAction?,
logGroupSummaries: Boolean,
): Boolean = logGroupSummaries || (
!snapshot.isGroupSummary && action != NotificationAction.GROUP_SUMMARY_CANCELLED
)
}
@@ -12,6 +12,9 @@ import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
import se.ajpanton.notificationlog.settings.AppFilterSettingsStore
import se.ajpanton.notificationlog.settings.PerAppEventSettingsStore
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
@@ -20,12 +23,18 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var logStore: EncryptedNotificationLogStore
private lateinit var writeExecutor: ExecutorService
private lateinit var ruleStore: LoggingRuleStore
private lateinit var captureSettings: CaptureSettingsStore
private lateinit var appFilterStore: AppFilterSettingsStore
private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var imageStore: EncryptedImageStore
override fun onCreate() {
super.onCreate()
logStore = EncryptedNotificationLogStore(this)
ruleStore = LoggingRuleStore(this)
captureSettings = CaptureSettingsStore(this)
appFilterStore = AppFilterSettingsStore(this)
perAppEventSettings = PerAppEventSettingsStore(this)
imageStore = EncryptedImageStore(this)
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer")
@@ -35,22 +44,41 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onListenerConnected() {
super.onListenerConnected()
getActiveNotifications()?.forEach { sbn ->
val snapshot = NotificationContents.snapshot(sbn, this)
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
activeNotifications[snapshot.key] = snapshot
record(snapshot, NotificationAction.ALREADY_ACTIVE, LoggingType.APPEARING, includeContents = true)
record(
snapshot,
NotificationAction.ALREADY_ACTIVE,
LoggingType.APPEARING,
includeContents = true,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
}
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
val snapshot = NotificationContents.snapshot(sbn, this)
val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot)
when {
previous == null -> record(snapshot, NotificationAction.APPEARED, LoggingType.APPEARING, includeContents = true)
previous == null -> record(
snapshot,
NotificationAction.APPEARED,
LoggingType.APPEARING,
includeContents = true,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
NotificationChangeClassifier.isMeaningfulEdit(previous, snapshot) &&
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) ->
record(snapshot, NotificationAction.EDITED, LoggingType.EDITS, includeContents = true, previousSnapshot = previous)
record(
snapshot,
NotificationAction.EDITED,
LoggingType.EDITS,
includeContents = true,
previousSnapshot = previous,
imageBytes = { NotificationContents.extractImageBytes(sbn.notification, this) },
)
}
}
@@ -59,7 +87,7 @@ class NotificationCaptureService : NotificationListenerService() {
rankingMap: RankingMap,
reason: Int,
) {
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn, this)
val snapshot = activeNotifications.remove(sbn.key) ?: NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
}
@@ -75,11 +103,13 @@ class NotificationCaptureService : NotificationListenerService() {
loggingType: LoggingType,
includeContents: Boolean,
previousSnapshot: NotificationSnapshot? = null,
imageBytes: (() -> ByteArray?)? = null,
) {
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
if (!GroupSummaryPolicy.shouldLog(snapshot, action, captureSettings.logGroupSummaries)) return
if (!allows(loggingType, snapshot.packageName)) return
val appName = appName(snapshot.packageName)
val retainImage = includeContents && snapshot.imageBytes != null &&
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName)
val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(),
eventTimeZoneId = java.util.TimeZone.getDefault().id,
@@ -88,13 +118,13 @@ class NotificationCaptureService : NotificationListenerService() {
action = action,
contents = if (includeContents) visibleContents(snapshot) else null,
previousContents = previousSnapshot?.let(::visibleContents),
imageId = if (retainImage) java.util.UUID.randomUUID().toString() else null,
imageId = if (retainedImageBytes != null) java.util.UUID.randomUUID().toString() else null,
)
writeExecutor.execute {
try {
if (retainImage) {
if (retainedImageBytes != null) {
try {
imageStore.save(entry.imageId!!, snapshot.imageBytes!!)
imageStore.save(entry.imageId!!, retainedImageBytes)
} catch (error: Exception) {
Log.w(TAG, "Could not retain notification image; keeping the text event", error)
logStore.append(entry.copy(imageId = null))
@@ -111,17 +141,25 @@ class NotificationCaptureService : NotificationListenerService() {
private fun visibleContents(snapshot: NotificationSnapshot): String? {
val text = snapshot.textContents?.takeIf {
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.TEXT_CONTENT), snapshot.packageName)
allows(LoggingType.TEXT_CONTENT, snapshot.packageName)
}
val image = snapshot.hasImage && NotificationRuleEvaluator.allows(
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
)
val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
return listOfNotNull(text, if (image) "[image]" else null)
.joinToString("")
.joinToString("\n")
.take(MAX_CONTENT_CHARACTERS)
.ifEmpty { null }
}
private fun allows(type: LoggingType, packageName: String): Boolean {
val globalEnabled = ruleStore.ruleFor(type).enabled
val eventEnabled = if (type in LoggingType.eventTypes) {
perAppEventSettings.isEnabled(packageName, type, globalEnabled)
} else {
globalEnabled
}
return eventEnabled && NotificationRuleEvaluator.allows(appFilterStore.load(), packageName)
}
private fun appName(packageName: String): String = try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString()
@@ -9,50 +9,89 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Icon
import android.os.Bundle
import android.service.notification.StatusBarNotification
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
data class NotificationSnapshot(
val key: String,
val packageName: String,
val textContents: String?,
val hasImage: Boolean,
val isGroupSummary: Boolean,
val isRoutine: Boolean,
val imageBytes: ByteArray?,
)
object NotificationContents {
fun extract(notification: Notification): String? {
val parts = linkedSetOf<String>()
val extras = notification.extras ?: Bundle.EMPTY
extras.getCharSequence(Notification.EXTRA_TITLE)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_TEXT)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.addTo(parts)
extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)?.addTo(parts)
extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.forEach { it?.addTo(parts) }
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
).forEach { message ->
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
}
return parts.takeIf { it.isNotEmpty() }?.joinToString("")
messagingContents(extras)?.let { return it }
return genericContents(
title = extras.getCharSequence(Notification.EXTRA_TITLE),
text = extras.getCharSequence(Notification.EXTRA_TEXT),
bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT),
subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT),
summaryText = extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT),
textLines = extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES).orEmpty().asList(),
)
}
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot {
/** Uses BigTextStyle's expanded content instead of its alternate collapsed rendering. */
internal fun genericContents(
title: CharSequence?,
text: CharSequence?,
bigText: CharSequence?,
subText: CharSequence?,
summaryText: CharSequence?,
textLines: List<CharSequence?>,
): String? {
val parts = linkedSetOf<String>()
title?.addTo(parts)
(bigText.takeIf { it.hasText() } ?: text)?.addTo(parts)
subText?.addTo(parts)
summaryText?.addTo(parts)
textLines.forEach { it?.addTo(parts) }
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
/** MessagingStyle also populates generic title/text fields with an alternate rendering. */
private fun messagingContents(extras: Bundle): String? {
val parts = linkedSetOf<String>()
listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).forEach { key ->
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(key, Bundle::class.java),
).forEach { message ->
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
}
}
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
}
fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
val notification = sbn.notification
val extras = notification.extras
val picture = extras?.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java)
val pictureIcon = extras?.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java)
return NotificationSnapshot(
key = sbn.key,
packageName = sbn.packageName,
textContents = extract(notification),
hasImage = extras?.let {
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
} == true,
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() },
)
key = sbn.key,
packageName = sbn.packageName,
textContents = extract(notification),
hasImage = extras?.let {
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
} == true,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
extras?.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) == true ||
extras?.let {
it.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 &&
it.getInt(Notification.EXTRA_PROGRESS, -1) >= 0
} == true,
)
}
/** Called only after the event has passed all capture filters. */
fun extractImageBytes(notification: Notification, context: Context): ByteArray? {
val extras = notification.extras ?: return null
val picture = extras.getParcelable(Notification.EXTRA_PICTURE, Bitmap::class.java)
val pictureIcon = extras.getParcelable(Notification.EXTRA_PICTURE_ICON, Icon::class.java)
return picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() }
}
private fun Drawable.toBitmap(): Bitmap? = when (this) {
@@ -63,7 +102,7 @@ object NotificationContents {
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(sourceWidth, sourceHeight))
val width = (sourceWidth * scale).toInt()
val height = (sourceHeight * scale).toInt()
Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap ->
createBitmap(width, height, Bitmap.Config.ARGB_8888).also { bitmap ->
setBounds(0, 0, width, height)
draw(Canvas(bitmap))
}
@@ -71,8 +110,8 @@ object NotificationContents {
}
private fun Bitmap.toPng(): ByteArray? = runCatching {
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(width, height))
val bitmap = if (scale < 1f) Bitmap.createScaledBitmap(this, (width * scale).toInt(), (height * scale).toInt(), true) else this
val scaleFactor = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(width, height))
val bitmap = if (scaleFactor < 1f) scale((width * scaleFactor).toInt(), (height * scaleFactor).toInt()) else this
java.io.ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
output.toByteArray()
@@ -83,5 +122,7 @@ object NotificationContents {
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
}
private fun CharSequence?.hasText(): Boolean = !this.isNullOrBlank()
private const val MAX_IMAGE_DIMENSION = 1600
}
@@ -47,6 +47,11 @@ class EncryptedImageStore(context: Context) {
}
}
fun exists(id: String): Boolean {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
return File(directory, "$id.bin").isFile
}
fun delete(id: String) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
File(directory, "$id.bin").delete()
@@ -3,55 +3,210 @@ package se.ajpanton.notificationlog.data
import android.content.Context
import android.util.AtomicFile
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.StorageLimitsStore
import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.io.FileNotFoundException
import java.io.File
import java.io.FileNotFoundException
data class NewestLogCursor(
val chunkName: String,
val entryIndex: Int,
)
data class NotificationLogPage(
val entries: List<NotificationLogEntry>,
val nextCursor: NewestLogCursor?,
)
/**
* An atomically replaced encrypted event log. It keeps every persisted log field
* encrypted, including timestamps and package names, while the app is at rest.
* Independently encrypted log chunks. Reading or appending a record needs only
* one bounded chunk; older chunks stay on disk until requested by the viewer.
*/
class EncryptedNotificationLogStore(context: Context) {
private val lock = Any()
private val file = AtomicFile(context.filesDir.resolve(FILE_NAME))
private val imageDirectory = File(context.filesDir, IMAGE_DIRECTORY_NAME)
private val appContext = context.applicationContext
private val directory = File(appContext.filesDir, DIRECTORY_NAME).also(File::mkdirs)
private val imageDirectory = File(appContext.filesDir, IMAGE_DIRECTORY_NAME)
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
fun readAll(): List<NotificationLogEntry> = synchronized(lock) {
val payload = readPayloadOrNull() ?: return emptyList()
NotificationLogEntryJson.decode(cipher.decrypt(payload))
fun readNewest(cursor: NewestLogCursor?, limit: Int): NotificationLogPage = synchronized(lock) {
require(limit > 0)
val result = ArrayList<NotificationLogEntry>(limit)
val chunks = chunkFiles().asReversed()
var chunkIndex = cursor?.let { saved -> chunks.indexOfFirst { it.name == saved.chunkName } } ?: 0
var entryIndex = cursor?.entryIndex ?: Int.MAX_VALUE
if (chunkIndex < 0) return@synchronized NotificationLogPage(emptyList(), null)
while (chunkIndex < chunks.size) {
val chunk = chunks[chunkIndex]
val entries = readChunk(chunk)
var index = minOf(entryIndex, entries.lastIndex)
while (index >= 0 && result.size < limit) {
result += entries[index--]
}
if (result.size == limit) {
val next = if (index >= 0) {
NewestLogCursor(chunk.name, index)
} else {
chunks.getOrNull(chunkIndex + 1)?.let { NewestLogCursor(it.name, Int.MAX_VALUE) }
}
return@synchronized NotificationLogPage(result, next)
}
chunkIndex++
entryIndex = Int.MAX_VALUE
}
NotificationLogPage(result, null)
}
/**
* Visits the current history newest first without collecting it in memory.
* Each encrypted chunk is decrypted while holding the store lock, then its
* entries are passed to [action] after releasing the lock so capture is not
* held up by a slow export destination.
*/
fun forEachNewest(action: (NotificationLogEntry) -> Unit) {
val chunks = synchronized(lock) { chunkFiles().asReversed() }
chunks.forEach { chunk ->
val entries = synchronized(lock) {
if (chunk.exists()) readChunk(chunk).asReversed() else emptyList()
}
entries.forEach(action)
}
}
fun append(entry: NotificationLogEntry) = synchronized(lock) {
val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList())
val retained = retain(entries + entry)
write(retained)
(entries.mapNotNull { it.imageId } - retained.mapNotNull { it.imageId }.toSet()).forEach(::deleteImage)
}
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
write(entries)
val chunks = chunkFiles()
val newest = chunks.lastOrNull()
val current = newest?.let(::readChunk).orEmpty()
if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) {
writeChunk(nextChunkFile(chunks), listOf(entry))
} else {
writeChunk(newest, current + entry)
}
enforceLimits()
}
fun clear() = synchronized(lock) {
file.delete()
clearChunksOnly()
imageDirectory.listFiles()?.forEach(File::delete)
}
fun delete(id: String): Boolean = synchronized(lock) {
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: return false
val entry = entries.firstOrNull { it.id == id } ?: return false
write(entries.filterNot { it.id == id })
entry.imageId?.let(::deleteImage)
true
chunkFiles().forEach { chunk ->
val entries = readChunk(chunk)
val entry = entries.firstOrNull { it.id == id } ?: return@forEach
val retained = entries.filterNot { it.id == id }
if (retained.isEmpty()) chunk.delete() else writeChunk(chunk, retained)
entry.imageId?.let(::deleteImage)
return true
}
false
}
private fun write(entries: List<NotificationLogEntry>) {
/** Removes failed-write remnants and images no current log entry references. */
fun removeOrphanedImages() = synchronized(lock) {
if (imageDirectory.listFiles().isNullOrEmpty()) return
val referenced = mutableSetOf<String>()
chunkFiles().forEach { chunk ->
readChunk(chunk).mapNotNullTo(referenced) { it.imageId }
}
removeUnreferencedImages(referenced)
}
fun enforceLimits() = synchronized(lock) {
val limits = StorageLimitsStore(appContext).load()
trimLogBytes(limits.logBytes)
trimImageBytes(limits.imageBytes)
}
private fun trimLogBytes(limit: Long) {
var chunks = chunkFiles()
var totalBytes = chunks.sumOf(File::length)
while (totalBytes > limit && chunks.isNotEmpty()) {
val oldest = chunks.first()
val entries = readChunk(oldest)
if (entries.isEmpty()) {
oldest.delete()
} else if (chunks.size > 1) {
entries.mapNotNull { it.imageId }.forEach(::deleteImage)
oldest.delete()
} else {
var retained = entries
while (totalBytes > limit && retained.isNotEmpty()) {
retained.first().imageId?.let(::deleteImage)
retained = retained.drop(1)
if (retained.isEmpty()) oldest.delete() else writeChunk(oldest, retained)
totalBytes = chunkFiles().sumOf(File::length)
}
}
chunks = chunkFiles()
totalBytes = chunks.sumOf(File::length)
}
}
private fun trimImageBytes(limit: Long) {
var imageBytes = imageDirectory.listFiles()?.filter(File::isFile)?.sumOf(File::length) ?: 0L
if (imageBytes <= limit) return
chunkFiles().forEach { chunk ->
if (imageBytes <= limit) return@forEach
readChunk(chunk).forEach { entry ->
if (imageBytes > limit && entry.imageId != null) {
val image = File(imageDirectory, "${entry.imageId}.bin")
imageBytes -= image.length()
// Keep imageId and the original `[image]` text marker in the
// log. The viewer and HTML exporter use the marker whenever
// this retained file is no longer available.
image.delete()
}
}
}
}
private fun removeUnreferencedImages(referenced: Set<String>) {
imageDirectory.listFiles()?.forEach { image ->
val imageId = image.name.removeSuffix(".bin")
if (image.name.endsWith(".new") ||
!image.name.endsWith(".bin") ||
!imageId.matches(IMAGE_ID_PATTERN) ||
imageId !in referenced
) {
image.delete()
}
}
}
private fun chunkFiles(): List<File> = directory.listFiles()
?.filter { it.isFile && it.name.startsWith(CHUNK_PREFIX) && it.name.endsWith(CHUNK_SUFFIX) }
?.sortedBy { it.name }
?: emptyList()
private fun nextChunkFile(chunks: List<File>): File {
val next = chunks.lastOrNull()?.name?.removePrefix(CHUNK_PREFIX)?.removeSuffix(CHUNK_SUFFIX)?.toLongOrNull()?.plus(1) ?: 0L
return chunkFile(next)
}
private fun chunkFile(sequence: Long): File = File(directory, "$CHUNK_PREFIX${sequence.toString().padStart(CHUNK_NAME_WIDTH, '0')}$CHUNK_SUFFIX")
private fun readChunk(file: File): List<NotificationLogEntry> {
val input = try {
AtomicFile(file).openRead()
} catch (_: FileNotFoundException) {
return emptyList()
}
DataInputStream(BufferedInputStream(input)).use { stream ->
require(stream.readInt() == FILE_VERSION) { "Unsupported encrypted notification-log chunk." }
val initializationVector = readByteArray(stream, MAX_IV_BYTES)
val cipherText = readByteArray(stream, MAX_CHUNK_CIPHER_TEXT_BYTES)
return NotificationLogEntryJson.decode(cipher.decrypt(EncryptedPayload(initializationVector, cipherText)))
}
}
private fun writeChunk(file: File, entries: List<NotificationLogEntry>) {
val payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
val output = file.startWrite()
val atomicFile = AtomicFile(file)
val output = atomicFile.startWrite()
try {
val stream = DataOutputStream(BufferedOutputStream(output))
stream.writeInt(FILE_VERSION)
@@ -60,56 +215,40 @@ class EncryptedNotificationLogStore(context: Context) {
stream.writeInt(payload.cipherText.size)
stream.write(payload.cipherText)
stream.flush()
file.finishWrite(output)
atomicFile.finishWrite(output)
} catch (error: Exception) {
file.failWrite(output)
atomicFile.failWrite(output)
throw error
}
}
private fun retain(entries: List<NotificationLogEntry>): List<NotificationLogEntry> {
var retained = entries.takeLast(MAX_ENTRIES)
while (retained.size > 1 && NotificationLogEntryJson.encode(retained).size > MAX_PLAINTEXT_BYTES) {
retained = retained.drop(1)
}
require(NotificationLogEntryJson.encode(retained).size <= MAX_PLAINTEXT_BYTES) {
"A notification event exceeds the encrypted log retention limit."
}
return retained
}
private fun deleteImage(id: String) {
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
}
private fun readPayloadOrNull(): EncryptedPayload? {
val input = try {
file.openRead()
} catch (_: FileNotFoundException) {
return null
}
DataInputStream(BufferedInputStream(input)).use { stream ->
require(stream.readInt() == FILE_VERSION) { "Unsupported encrypted notification-log version." }
val initializationVector = readByteArray(stream, MAX_IV_BYTES)
val cipherText = readByteArray(stream, MAX_CIPHER_TEXT_BYTES)
return EncryptedPayload(initializationVector, cipherText)
}
}
private fun readByteArray(stream: DataInputStream, maximumLength: Int): ByteArray {
val length = stream.readInt()
require(length in 1..maximumLength) { "Invalid encrypted notification-log field length." }
return ByteArray(length).also(stream::readFully)
}
private fun encodedSize(entries: List<NotificationLogEntry>) = NotificationLogEntryJson.encode(entries).size
private fun clearChunksOnly() {
chunkFiles().forEach(File::delete)
}
private fun deleteImage(id: String) {
if (id.matches(IMAGE_ID_PATTERN)) File(imageDirectory, "$id.bin").delete()
}
private companion object {
const val FILE_NAME = "notification-log.v1"
val lock = Any()
const val DIRECTORY_NAME = "notification-log"
const val IMAGE_DIRECTORY_NAME = "notification-images"
const val CHUNK_PREFIX = "chunk-"
const val CHUNK_SUFFIX = ".bin"
const val CHUNK_NAME_WIDTH = 12
const val FILE_VERSION = 1
const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024
const val MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024
const val MAX_ENTRIES = 10_000
const val IMAGE_DIRECTORY_NAME = "notification-images"
const val MAX_CHUNK_CIPHER_TEXT_BYTES = 1024 * 1024
const val MAX_CHUNK_PLAINTEXT_BYTES = 256 * 1024
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
}
}
@@ -1,55 +1,77 @@
package se.ajpanton.notificationlog.export
import android.content.Context
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.TimestampZone
import java.text.DateFormat
import java.util.Date
import java.util.TimeZone
import se.ajpanton.notificationlog.settings.TimestampFormatter
object LogExporter {
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings): String =
rows(entries, settings).joinToString("\n") { row -> row.joinToString(",") { csvValue(it) } }
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String = (
listOf(csvHeader(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { csvRow(it, settings, context) }
).joinToString("\n")
fun formatted(entries: List<NotificationLogEntry>, settings: LogViewSettings): String {
val rows = rows(entries, settings)
fun formatted(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String {
val rows = listOf(headers(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { values(it, settings, context) }
val widths = rows.fold(emptyList<Int>()) { current, row -> row.mapIndexed { i, value -> maxOf(current.getOrElse(i) { 0 }, value.length) } }
return rows.joinToString("\n") { row -> row.mapIndexed { i, value -> value.padEnd(widths[i]) }.joinToString(" ") }
return rows.joinToString("\n") { formattedRow(it, widths) }
}
fun headers(settings: LogViewSettings): List<String> = fields(settings).map(::header)
fun values(entry: NotificationLogEntry, settings: LogViewSettings, context: Context? = null): List<String> = fields(settings).map { field -> when (field) {
LogField.TIMESTAMP -> timestamp(context, entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
LogField.CONTENTS -> entry.contents ?: ""
} }
fun csvHeader(settings: LogViewSettings): String = csvLine(headers(settings))
fun csvRow(entry: NotificationLogEntry, settings: LogViewSettings, context: Context? = null): String = csvLine(values(entry, settings, context))
fun formattedRow(values: List<String>, widths: List<Int>): String =
values.mapIndexed { index, value -> value.padEnd(widths[index]) }.joinToString(" ")
fun htmlStart(settings: LogViewSettings): String = buildString {
append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>")
fields(settings).forEach { append("<th>${escape(header(it))}</th>") }
append("</tr></thead><tbody>")
}
fun htmlRow(
entry: NotificationLogEntry,
settings: LogViewSettings,
imagePath: String? = null,
context: Context? = null,
): String = buildString {
append("<tr>")
fields(settings).forEach { field -> append("<td>${htmlValue(field, entry, settings, imagePath, context)}</td>") }
append("</tr>")
}
fun htmlEnd(): String = "</tbody></table>"
fun html(
entries: List<NotificationLogEntry>,
settings: LogViewSettings,
context: Context? = null,
imagePath: (NotificationLogEntry) -> String? = { null },
): String = buildString {
append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>")
settings.order.filter { it in settings.visibleFields }.forEach { append("<th>${escape(header(it))}</th>") }
append("</tr></thead><tbody>")
append(htmlStart(settings))
entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry ->
append("<tr>")
settings.order.filter { it in settings.visibleFields }.forEach { field ->
append("<td>${htmlValue(field, entry, settings, imagePath(entry))}</td>")
}
append("</tr>")
append(htmlRow(entry, settings, imagePath(entry), context))
}
append("</tbody></table>")
append(htmlEnd())
}
private fun rows(entries: List<NotificationLogEntry>, settings: LogViewSettings) = listOf(
settings.order.filter { it in settings.visibleFields }.map(::header),
) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { entry ->
settings.order.filter { it in settings.visibleFields }.map { field -> when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName; LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
LogField.CONTENTS -> entry.contents ?: ""
} }
}
private fun fields(settings: LogViewSettings): List<LogField> = settings.order.filter { it in settings.visibleFields }
private fun header(field: LogField) = field.name.lowercase().replace('_', ' ')
private fun htmlValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings, imagePath: String?): String {
private fun htmlValue(field: LogField, entry: NotificationLogEntry, settings: LogViewSettings, imagePath: String?, context: Context?): String {
val value = when (field) {
LogField.TIMESTAMP -> timestamp(entry.recordedAtEpochMillis, settings.timestampZone, entry.eventTimeZoneId)
LogField.TIMESTAMP -> timestamp(context, entry.recordedAtEpochMillis, settings, entry.eventTimeZoneId)
LogField.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
@@ -62,13 +84,9 @@ object LogExporter {
escaped
}
}
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply {
timeZone = when (zone) {
TimestampZone.UTC -> TimeZone.getTimeZone("UTC")
TimestampZone.LOCAL_NOW -> TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(TimeZone::getTimeZone) ?: TimeZone.getDefault()
}
}.format(Date(value))
private fun timestamp(context: Context?, value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String =
TimestampFormatter.format(context, value, settings, eventTimeZoneId)
private fun csvLine(values: List<String>): String = values.joinToString(",") { csvValue(it) }
private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\""
private fun escape(value: String) = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;")
}
@@ -41,7 +41,6 @@ enum class NotificationAction {
GROUP_SUMMARY_CANCELLED,
GROUP_OPTIMIZED,
UNAUTOBUNDLED,
BUNDLE_DISMISSED,
CLEAR_DATA,
ASSISTANT_CANCELLED,
LOCKDOWN,
@@ -0,0 +1,45 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
data class AppFilterSettings(
val mode: AppRuleMode = AppRuleMode.BLACKLIST,
val selectedPackages: Set<String> = emptySet(),
val onlySeenApps: Boolean = false,
val seenAppsFirst: Boolean = true,
)
class AppFilterSettingsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun load(): AppFilterSettings = AppFilterSettings(
mode = AppRuleMode.valueOf(preferences.getString(MODE, AppRuleMode.BLACKLIST.name)!!),
selectedPackages = preferences.getStringSet(SELECTED_PACKAGES, emptySet())?.toSet() ?: emptySet(),
onlySeenApps = preferences.getBoolean(ONLY_SEEN_APPS, false),
seenAppsFirst = preferences.getBoolean(SEEN_APPS_FIRST, true),
)
fun save(settings: AppFilterSettings) = preferences.edit {
putString(MODE, settings.mode.name)
putStringSet(SELECTED_PACKAGES, settings.selectedPackages)
putBoolean(ONLY_SEEN_APPS, settings.onlySeenApps)
putBoolean(SEEN_APPS_FIRST, settings.seenAppsFirst)
}
fun removeUninstalledPackages(installedPackages: Set<String>) {
val settings = load()
val retainedPackages = settings.selectedPackages.intersect(installedPackages)
if (retainedPackages != settings.selectedPackages) {
save(settings.copy(selectedPackages = retainedPackages))
}
}
private companion object {
const val FILE_NAME = "app-filter-settings"
const val MODE = "mode"
const val SELECTED_PACKAGES = "selected_packages"
const val ONLY_SEEN_APPS = "only_seen_apps"
const val SEEN_APPS_FIRST = "seen_apps_first"
}
}
@@ -1,24 +1,45 @@
package se.ajpanton.notificationlog.settings
data class ListedApp(val label: String, val packageName: String, val seen: Boolean, val selected: Boolean)
data class ListedApp(
val label: String,
val packageName: String,
val seen: Boolean,
val selected: Boolean,
val hasEventOverride: Boolean = false,
) {
val edited: Boolean get() = selected || hasEventOverride
}
sealed interface AppListItem {
data class App(val value: ListedApp) : AppListItem
data class SectionTitle(val value: String) : AppListItem
data object Separator : AppListItem
}
object AppListOrdering {
fun items(allApps: List<ListedApp>, onlySeen: Boolean, seenAppsFirst: Boolean): List<AppListItem> {
fun items(
allApps: List<ListedApp>,
onlySeen: Boolean,
seenAppsFirst: Boolean,
): List<AppListItem> {
val apps = allApps.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.label })
val shown = if (onlySeen) apps.filter { it.seen || it.selected } else apps
val shown = if (onlySeen) apps.filter { it.seen || it.edited } else apps
val seen = shown.filter { it.seen }
val other = shown.filterNot { it.seen }
val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty()
val editedUnseen = shown.filter { !it.seen }
if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App)
return buildList {
if (separate) {
seen.forEach { add(AppListItem.App(it)) }
add(AppListItem.Separator)
other.forEach { add(AppListItem.App(it)) }
if (!onlySeen) add(AppListItem.SectionTitle("Seen apps"))
if (onlySeen && editedUnseen.isNotEmpty()) add(AppListItem.SectionTitle("Seen apps"))
seen.forEach { add(AppListItem.App(it)) }
if (onlySeen) {
if (editedUnseen.isNotEmpty()) {
add(AppListItem.Separator)
add(AppListItem.SectionTitle("Edited apps"))
editedUnseen.forEach { add(AppListItem.App(it)) }
}
} else {
add(AppListItem.Separator)
add(AppListItem.SectionTitle("All apps"))
// The full alphabetical list intentionally repeats seen apps after its quick-access group.
shown.forEach { add(AppListItem.App(it)) }
}
}
@@ -1,10 +1,11 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
class AppLockStore(context: Context) {
private val prefs = context.getSharedPreferences("app-lock", Context.MODE_PRIVATE)
var enabled: Boolean
get() = prefs.getBoolean("enabled", false)
set(value) = prefs.edit().putBoolean("enabled", value).apply()
set(value) = prefs.edit { putBoolean("enabled", value) }
}
@@ -0,0 +1,18 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
/** Global capture choices that are independent of a particular event type. */
class CaptureSettingsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
var logGroupSummaries: Boolean
get() = preferences.getBoolean(LOG_GROUP_SUMMARIES, false)
set(value) = preferences.edit { putBoolean(LOG_GROUP_SUMMARIES, value) }
private companion object {
const val FILE_NAME = "capture-settings"
const val LOG_GROUP_SUMMARIES = "log_group_summaries"
}
}
@@ -0,0 +1,28 @@
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 selectedApps(): List<App> = selectedPackages().map { packageName ->
App(prefs.getString("name:$packageName", packageName).orEmpty(), packageName)
}
fun save(apps: Collection<App>) = prefs.edit {
val packages = apps.mapTo(mutableSetOf()) { it.packageName }
prefs.all.keys.filter { it.startsWith("name:") && it.removePrefix("name:") !in packages }.forEach(::remove)
putStringSet("packages", packages)
apps.forEach { putString("name:${it.packageName}", it.name) }
}
fun clear() = prefs.edit {
remove("packages")
prefs.all.keys.filter { it.startsWith("name:") }.forEach(::remove)
}
data class App(val name: String, val packageName: String)
}
@@ -1,10 +1,16 @@
package se.ajpanton.notificationlog.settings
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS }
enum class TimestampZone { LOCAL_NOW, UTC, EVENT_LOCAL }
enum class TimestampZone { EVENT_LOCAL, LOCAL_NOW, UTC }
enum class TimestampDateFormat { SYSTEM_DEFAULT, YEAR_MONTH_DAY, DAY_MONTH_YEAR, MONTH_DAY_YEAR }
enum class TimestampClockFormat { SYSTEM_DEFAULT, HOUR_24, HOUR_12 }
enum class DisplayEvent { APPEARING, DISAPPEARING, EDITS }
data class LogViewSettings(
val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS),
val order: List<LogField> = LogField.entries,
val timestampZone: TimestampZone = TimestampZone.LOCAL_NOW,
val timestampZone: TimestampZone = TimestampZone.EVENT_LOCAL,
val timestampDateFormat: TimestampDateFormat = TimestampDateFormat.SYSTEM_DEFAULT,
val timestampClockFormat: TimestampClockFormat = TimestampClockFormat.SYSTEM_DEFAULT,
val visibleEvents: Set<DisplayEvent> = DisplayEvent.entries.toSet(),
)
@@ -9,11 +9,18 @@ class LogViewSettingsStore(context: Context) {
visibleFields = prefs.getStringSet("visible", LogViewSettings().visibleFields.map { it.name }.toSet())!!
.map(LogField::valueOf).toSet(),
order = prefs.getString("order", null)?.split(',')?.map(LogField::valueOf) ?: LogField.entries,
timestampZone = TimestampZone.valueOf(prefs.getString("zone", TimestampZone.LOCAL_NOW.name)!!),
timestampZone = TimestampZone.valueOf(prefs.getString("zone", TimestampZone.EVENT_LOCAL.name)!!),
timestampDateFormat = TimestampDateFormat.valueOf(prefs.getString("date_format", TimestampDateFormat.SYSTEM_DEFAULT.name)!!),
timestampClockFormat = TimestampClockFormat.valueOf(prefs.getString("clock_format", TimestampClockFormat.SYSTEM_DEFAULT.name)!!),
visibleEvents = prefs.getStringSet("visible_events", DisplayEvent.entries.map { it.name }.toSet())!!
.map(DisplayEvent::valueOf).toSet(),
)
fun save(settings: LogViewSettings) = prefs.edit {
putStringSet("visible", settings.visibleFields.map { it.name }.toSet())
putString("order", settings.order.joinToString(",") { it.name })
putString("zone", settings.timestampZone.name)
putString("date_format", settings.timestampDateFormat.name)
putString("clock_format", settings.timestampClockFormat.name)
putStringSet("visible_events", settings.visibleEvents.map { it.name }.toSet())
}
}
@@ -10,14 +10,6 @@ class LoggingRuleStore(context: Context) {
fun ruleFor(type: LoggingType): LoggingRule = LoggingRule(
enabled = preferences.getBoolean(key(type, "enabled"), true),
appRuleMode = AppRuleMode.valueOf(
preferences.getString(key(type, "app_rule_mode"), AppRuleMode.BLACKLIST.name)
?: AppRuleMode.BLACKLIST.name,
),
selectedPackages = preferences.getStringSet(key(type, "selected_packages"), emptySet())?.toSet()
?: emptySet(),
onlySeenApps = preferences.getBoolean(key(type, "only_seen_apps"), false),
seenAppsFirst = preferences.getBoolean(key(type, "seen_apps_first"), true),
ignoreRoutineUpdates = preferences.getBoolean(key(type, "ignore_routine_updates"), true),
)
@@ -28,34 +20,22 @@ class LoggingRuleStore(context: Context) {
updateListenerComponent()
}
fun copy(from: LoggingType, targets: Set<LoggingType>) {
val source = ruleFor(from).copyForTarget()
preferences.edit {
targets.filterNot { it == from }.forEach { target ->
write(target, source.copy(selectedPackages = source.selectedPackages.toSet()))
}
}
updateListenerComponent()
}
private fun android.content.SharedPreferences.Editor.write(type: LoggingType, rule: LoggingRule) {
putBoolean(key(type, "enabled"), rule.enabled)
putString(key(type, "app_rule_mode"), rule.appRuleMode.name)
putStringSet(key(type, "selected_packages"), rule.selectedPackages)
putBoolean(key(type, "only_seen_apps"), rule.onlySeenApps)
putBoolean(key(type, "seen_apps_first"), rule.seenAppsFirst)
putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates)
}
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
private fun updateListenerComponent() {
NotificationListenerComponentController.update(appContext, LoggingType.entries.map(::ruleFor))
NotificationListenerComponentController.update(
appContext,
LoggingType.entries.associateWith(::ruleFor),
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
)
}
private companion object {
const val FILE_NAME = "logging-rules"
}
}
internal fun LoggingRule.copyForTarget(): LoggingRule = copy(selectedPackages = selectedPackages.toSet())
@@ -6,6 +6,12 @@ enum class LoggingType {
TEXT_CONTENT,
IMAGE_CONTENT,
EDITS,
;
companion object {
val eventTypes = listOf(APPEARING, DISAPPEARING, EDITS)
val contentTypes = listOf(TEXT_CONTENT, IMAGE_CONTENT)
}
}
enum class AppRuleMode {
@@ -15,9 +21,5 @@ enum class AppRuleMode {
data class LoggingRule(
val enabled: Boolean = true,
val appRuleMode: AppRuleMode = AppRuleMode.BLACKLIST,
val selectedPackages: Set<String> = emptySet(),
val onlySeenApps: Boolean = false,
val seenAppsFirst: Boolean = true,
val ignoreRoutineUpdates: Boolean = true,
)
@@ -13,19 +13,23 @@ import se.ajpanton.notificationlog.capture.NotificationCaptureService
* is therefore both the no-work battery mode and the no-start-on-boot mode.
*/
internal object NotificationListenerComponentController {
fun update(context: Context, rules: Collection<LoggingRule>) {
fun update(context: Context, rules: Map<LoggingType, LoggingRule>, hasEnabledEventOverride: Boolean = false) {
val applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules)) {
val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
if (packageManager.getComponentEnabledSetting(component) == desired) return
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
if (packageManager.getComponentEnabledSetting(component) != desired) {
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
}
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
try {
// Data clearing and OEM listener management can leave an enabled
// component without a live binding. Ask Android to reconnect on
// every enabled-rule synchronization, not just a state change.
NotificationListenerService.requestRebind(component)
} catch (error: SecurityException) {
Log.w(TAG, "Notification access is not granted yet; Android will bind after the user grants it", error)
@@ -2,5 +2,8 @@ package se.ajpanton.notificationlog.settings
/** The listener is useful only when at least one event type is enabled. */
object NotificationListenerPolicy {
fun shouldRun(rules: Collection<LoggingRule>): Boolean = rules.any { it.enabled }
fun shouldRun(
rules: Map<LoggingType, LoggingRule>,
hasEnabledEventOverride: Boolean = false,
): Boolean = LoggingType.eventTypes.any { rules.getValue(it).enabled } || hasEnabledEventOverride
}
@@ -1,10 +1,9 @@
package se.ajpanton.notificationlog.settings
object NotificationRuleEvaluator {
fun allows(rule: LoggingRule, packageName: String): Boolean {
if (!rule.enabled) return false
val selected = packageName in rule.selectedPackages
return when (rule.appRuleMode) {
fun allows(filter: AppFilterSettings, packageName: String): Boolean {
val selected = packageName in filter.selectedPackages
return when (filter.mode) {
AppRuleMode.WHITELIST -> selected
AppRuleMode.BLACKLIST -> !selected
}
@@ -0,0 +1,65 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
/** Event logging choices that replace the global event switches for one app. */
class PerAppEventSettingsStore(context: Context) {
private val appContext = context.applicationContext
private val preferences = appContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun usesGlobal(packageName: String): Boolean = !preferences.contains(key(packageName))
fun isEnabled(packageName: String, type: LoggingType, globalEnabled: Boolean): Boolean =
preferences.getStringSet(key(packageName), null)?.let { type.name in it } ?: globalEnabled
fun useGlobal(packageName: String) {
preferences.edit { remove(key(packageName)) }
updateListenerComponent()
}
fun startUsingOverride(packageName: String, globalEvents: Map<LoggingType, Boolean>) {
saveEvents(packageName, globalEvents.filterValues { it }.keys)
}
fun setEnabled(packageName: String, type: LoggingType, enabled: Boolean) {
val events = preferences.getStringSet(key(packageName), emptySet()).orEmpty().toMutableSet()
if (enabled) events.add(type.name) else events.remove(type.name)
preferences.edit { putStringSet(key(packageName), events) }
updateListenerComponent()
}
fun hasOverride(packageName: String): Boolean = !usesGlobal(packageName)
fun hasEnabledEventOverride(): Boolean = preferences.all
.filterKeys { it.startsWith(KEY_PREFIX) }
.values
.any { value -> (value as? Set<*>)?.isNotEmpty() == true }
fun removeUninstalledPackages(installedPackages: Set<String>) {
val removed = preferences.all.keys
.filter { it.startsWith(KEY_PREFIX) }
.filter { it.removePrefix(KEY_PREFIX) !in installedPackages }
if (removed.isNotEmpty()) {
preferences.edit { removed.forEach(::remove) }
updateListenerComponent()
}
}
private fun saveEvents(packageName: String, enabledEvents: Set<LoggingType>) {
preferences.edit { putStringSet(key(packageName), enabledEvents.mapTo(mutableSetOf()) { it.name }) }
updateListenerComponent()
}
private fun updateListenerComponent() {
val rules = LoggingType.entries.associateWith { LoggingRuleStore(appContext).ruleFor(it) }
NotificationListenerComponentController.update(appContext, rules, hasEnabledEventOverride())
}
private fun key(packageName: String) = "$KEY_PREFIX$packageName"
private companion object {
const val FILE_NAME = "per-app-event-settings"
const val KEY_PREFIX = "events:"
}
}
@@ -0,0 +1,30 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import androidx.core.content.edit
data class StorageLimits(
val logBytes: Long = 5 * 1024L * 1024L,
val imageBytes: Long = 100 * 1024L * 1024L,
)
class StorageLimitsStore(context: Context) {
private val preferences = context.applicationContext.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
fun load() = StorageLimits(
logBytes = preferences.getLong(LOG_BYTES, StorageLimits().logBytes),
imageBytes = preferences.getLong(IMAGE_BYTES, StorageLimits().imageBytes),
)
fun save(limits: StorageLimits) = preferences.edit {
putLong(LOG_BYTES, limits.logBytes)
putLong(IMAGE_BYTES, limits.imageBytes)
}
companion object {
const val MEBIBYTE = 1024L * 1024L
const val FILE_NAME = "storage-limits"
const val LOG_BYTES = "log_bytes"
const val IMAGE_BYTES = "image_bytes"
}
}
@@ -0,0 +1,42 @@
package se.ajpanton.notificationlog.settings
import android.content.Context
import android.text.format.DateFormat as AndroidDateFormat
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
object TimestampFormatter {
fun format(context: Context?, value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String {
val date = Date(value)
val timeZone = when (settings.timestampZone) {
TimestampZone.UTC -> TimeZone.getTimeZone("UTC")
TimestampZone.LOCAL_NOW -> TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(TimeZone::getTimeZone) ?: TimeZone.getDefault()
}
if (context == null &&
settings.timestampDateFormat == TimestampDateFormat.SYSTEM_DEFAULT &&
settings.timestampClockFormat == TimestampClockFormat.SYSTEM_DEFAULT
) {
return DateFormat.getDateTimeInstance().apply { this.timeZone = timeZone }.format(date)
}
return "${dateFormatter(settings.timestampDateFormat, timeZone).format(date)} ${clockFormatter(context, settings.timestampClockFormat, timeZone).format(date)}"
}
private fun dateFormatter(format: TimestampDateFormat, timeZone: TimeZone): DateFormat = when (format) {
TimestampDateFormat.SYSTEM_DEFAULT -> DateFormat.getDateInstance()
TimestampDateFormat.YEAR_MONTH_DAY -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
TimestampDateFormat.DAY_MONTH_YEAR -> SimpleDateFormat("dd-MM-yyyy", Locale.getDefault())
TimestampDateFormat.MONTH_DAY_YEAR -> SimpleDateFormat("MM-dd-yyyy", Locale.getDefault())
}.apply { this.timeZone = timeZone }
private fun clockFormatter(context: Context?, format: TimestampClockFormat, timeZone: TimeZone): DateFormat = when (format) {
TimestampClockFormat.SYSTEM_DEFAULT -> context?.let {
SimpleDateFormat(if (AndroidDateFormat.is24HourFormat(it)) "HH:mm:ss" else "hh:mm:ss a", Locale.getDefault())
} ?: DateFormat.getTimeInstance()
TimestampClockFormat.HOUR_24 -> SimpleDateFormat("HH:mm:ss", Locale.getDefault())
TimestampClockFormat.HOUR_12 -> SimpleDateFormat("hh:mm:ss a", Locale.getDefault())
}.apply { this.timeZone = timeZone }
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/dropdown_popup_background" />
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="@color/dropdown_popup_outline" />
</shape>
@@ -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,5h18v2H3zM6,11h12v2H6zM10,17h4v2h-4z" />
</vector>
@@ -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>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/log_row_separator" />
</shape>
+30 -6
View File
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<se.ajpanton.notificationlog.SafeInsetDrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<se.ajpanton.notificationlog.SafeInsetDrawerLayout
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
@@ -24,11 +28,10 @@
app:itemTextColor="@color/drawer_item_text_color"
app:menu="@menu/drawer_menu" />
<LinearLayout
<RelativeLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical">
android:layout_weight="1">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
@@ -37,6 +40,7 @@
android:background="@color/primary"
android:elevation="0dp"
android:theme="@style/ThemeOverlay.NotificationLog.Toolbar"
android:layout_alignParentTop="true"
app:navigationIconTint="@color/on_primary"
app:titleTextColor="@color/on_primary" />
@@ -44,8 +48,9 @@
android:id="@+id/content_frame"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
android:layout_below="@id/toolbar"
android:layout_alignParentBottom="true" />
</RelativeLayout>
</LinearLayout>
<com.google.android.material.navigation.NavigationView
@@ -61,3 +66,22 @@
app:menu="@menu/drawer_menu" />
</se.ajpanton.notificationlog.SafeInsetDrawerLayout>
<LinearLayout
android:id="@+id/lock_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/window_background"
android:clickable="true"
android:focusable="true"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Notification Log is locked"
android:textAppearance="?attr/textAppearanceHeadline6" />
</LinearLayout>
</FrameLayout>
@@ -15,11 +15,4 @@
android:textColor="@color/on_primary"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/drawer_subtitle"
android:textAppearance="?attr/textAppearanceCaption"
android:textColor="@color/on_primary" />
</LinearLayout>
@@ -1,12 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/app_list_refresh" android:layout_width="match_parent" android:layout_height="match_parent"><ScrollView android:id="@+id/app_scroll" android:layout_width="match_parent" android:layout_height="match_parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding">
<TextView android:id="@+id/page_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceHeadline5" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/master_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Logging enabled" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/app_rule_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/only_seen_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/seen_first_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Show seen apps at the top" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/routine_updates_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Ignore routine updates" />
<LinearLayout android:id="@+id/app_list" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:orientation="vertical" />
</LinearLayout>
</ScrollView></androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/page_padding">
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/app_rule_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/only_seen_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Show only seen and edited apps" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/seen_first_toggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Show seen apps first" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_weight="1">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/app_list_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/app_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:fastScrollAlwaysVisible="true"
android:fastScrollEnabled="true"
android:paddingBottom="16dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<LinearLayout
android:id="@+id/app_list_loading"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Loading apps…" />
</LinearLayout>
</FrameLayout>
</LinearLayout>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Events to log" />
<LinearLayout android:id="@+id/event_toggles" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Content to log" />
<LinearLayout android:id="@+id/content_toggles" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="Special filters" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/group_summaries" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Log Android group summaries" />
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/routine_updates" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Ignore detected routine updates" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Filter events" />
<LinearLayout android:id="@+id/event_toggles" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:text="On each line (long-press and drag to reorder)" />
<LinearLayout android:id="@+id/field_toggles" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" />
</LinearLayout>
</ScrollView>
+30 -75
View File
@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:importantForAutofill="noExcludeDescendants">
<LinearLayout
android:layout_width="match_parent"
@@ -9,77 +10,11 @@
android:orientation="vertical"
android:padding="@dimen/page_padding">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Settings"
android:textAppearance="?attr/textAppearanceHeadline5" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Event logging" />
<LinearLayout
android:id="@+id/event_master_toggles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Show on each log line (long-press and drag to reorder)" />
<LinearLayout
android:id="@+id/field_toggles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Timestamp timezone" />
<Spinner
android:id="@+id/timezone"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Copy event settings" />
<Spinner
android:id="@+id/copy_from"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<Button
android:id="@+id/copy_to"
android:id="@+id/notification_access"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="Choose targets" />
<Button
android:id="@+id/copy_event_settings"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:enabled="false"
android:text="Copy settings" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Logs" />
android:text="Enable notification access" />
<Button
android:id="@+id/export_logs"
@@ -93,17 +28,37 @@
android:layout_height="wrap_content"
android:text="Clear logs" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Capture limits: standard title, text, messaging, inbox, big-text, and readable big-picture content can be logged. Android may withhold OTPs, custom notification layouts, or image data; those cannot be recovered. App lists require broad installed-app visibility and may need a Play policy declaration for distribution." />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/app_lock"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Lock app with phone unlock" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="Storage limits (MiB)" />
<EditText
android:id="@+id/log_limit_mib"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Log storage limit (MiB)"
android:inputType="number" />
<EditText
android:id="@+id/image_limit_mib"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Image storage limit (MiB)"
android:inputType="number" />
<Button
android:id="@+id/save_storage_limits"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Save storage limits" />
</LinearLayout>
</ScrollView>
+28 -51
View File
@@ -1,61 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="@dimen/page_padding">
android:layout_height="match_parent">
<Button
android:id="@+id/notification_access"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable notification access" />
<LinearLayout
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/logs_refresh"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:layout_height="match_parent">
<Button
android:id="@+id/export_logs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Export logs" />
<Button
android:id="@+id/clear_logs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Clear logs" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/log_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingStart="@dimen/log_page_horizontal_padding"
android:paddingTop="@dimen/page_padding"
android:paddingEnd="@dimen/log_page_horizontal_padding"
android:paddingBottom="@dimen/page_padding" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<TextView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Tap a log to expand it. Long-press a log to delete it." />
android:layout_margin="24dp"
android:text="No logs yet." />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/empty_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:text="No logs yet." />
<LinearLayout
android:id="@+id/log_rows"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/scroll_to_top"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_margin="16dp"
android:contentDescription="Scroll to top"
android:visibility="gone"
android:src="@android:drawable/arrow_up_float" />
</FrameLayout>
+7 -18
View File
@@ -5,29 +5,18 @@
android:id="@+id/nav_view_logs"
android:title="@string/page_view_logs" />
</group>
<item
android:id="@+id/nav_settings_header"
android:checkable="false"
android:enabled="false"
android:title="@string/navigation_settings_header" />
<group android:checkableBehavior="single">
<item
android:id="@+id/nav_settings"
android:title="@string/navigation_indented_settings" />
android:title="@string/navigation_settings_header" />
<item
android:id="@+id/nav_appearing"
android:title="@string/navigation_indented_appearing" />
android:id="@+id/nav_log_display"
android:title="@string/navigation_indented_log_display" />
<item
android:id="@+id/nav_disappearing"
android:title="@string/navigation_indented_disappearing" />
android:id="@+id/nav_filter_logging"
android:title="@string/navigation_indented_filter_logging" />
<item
android:id="@+id/nav_text_content"
android:title="@string/navigation_indented_text_content" />
<item
android:id="@+id/nav_image_content"
android:title="@string/navigation_indented_image_content" />
<item
android:id="@+id/nav_edits"
android:title="@string/navigation_indented_edits" />
android:id="@+id/nav_filter_apps"
android:title="@string/navigation_indented_filter_apps" />
</group>
</menu>
+7
View File
@@ -8,4 +8,11 @@
<color name="surface">#121318</color>
<color name="on_surface">#E3E2E9</color>
<color name="window_background">#121318</color>
<color name="log_row_separator">#8F8D96</color>
<color name="dropdown_popup_background">#25262D</color>
<color name="dropdown_popup_outline">#C7C5CF</color>
<color name="app_filter_inherited_background">#2D2E34</color>
<color name="app_filter_inherited_icon">#000000</color>
<color name="app_filter_override_background">#4A4B53</color>
<color name="app_filter_override_icon">#B8CCFF</color>
</resources>
+7
View File
@@ -8,4 +8,11 @@
<color name="surface">#FBF8FF</color>
<color name="on_surface">#1B1B21</color>
<color name="window_background">#FBF8FF</color>
<color name="log_row_separator">#A5A2AB</color>
<color name="dropdown_popup_background">#FFFFFF</color>
<color name="dropdown_popup_outline">#64646C</color>
<color name="app_filter_inherited_background">#DFDFE5</color>
<color name="app_filter_inherited_icon">#FFFFFF</color>
<color name="app_filter_override_background">#D0D0D8</color>
<color name="app_filter_override_icon">#34588E</color>
</resources>
+2 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="navigation_drawer_width">216dp</dimen>
<dimen name="navigation_drawer_width">159dp</dimen>
<dimen name="folded_portrait_width">360dp</dimen>
<dimen name="navigation_menu_item_height">48dp</dimen>
<dimen name="drawer_header_height">86dp</dimen>
@@ -8,4 +8,5 @@
<dimen name="drawer_header_max_height">172dp</dimen>
<dimen name="drawer_header_padding">16dp</dimen>
<dimen name="page_padding">24dp</dimen>
<dimen name="log_page_horizontal_padding">12dp</dimen>
</resources>
+7 -13
View File
@@ -1,22 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Notification Log</string>
<string name="drawer_subtitle">Notification history</string>
<string name="notification_listener_label">Notification Log listener</string>
<string name="navigation_open">Open navigation</string>
<string name="navigation_close">Close navigation</string>
<string name="navigation_settings_header">Settings:</string>
<string name="navigation_settings_header">Settings</string>
<string name="page_view_logs">View logs</string>
<string name="page_settings">Settings</string>
<string name="page_appearing">Appearing</string>
<string name="page_disappearing">Disappearing</string>
<string name="page_text_content">Text content</string>
<string name="page_image_content">Image content</string>
<string name="page_edits">Edits</string>
<string name="navigation_indented_settings">&#160;&#160;&#160;&#160;Settings</string>
<string name="navigation_indented_appearing">&#160;&#160;&#160;&#160;Appearing</string>
<string name="navigation_indented_disappearing">&#160;&#160;&#160;&#160;Disappearing</string>
<string name="navigation_indented_text_content">&#160;&#160;&#160;&#160;Text content</string>
<string name="navigation_indented_image_content">&#160;&#160;&#160;&#160;Image content</string>
<string name="navigation_indented_edits">&#160;&#160;&#160;&#160;Edits</string>
<string name="page_filter_apps">Filter apps</string>
<string name="page_log_display">Log display</string>
<string name="page_filter_logging">Filter logging</string>
<string name="navigation_indented_log_display">&#160;&#160;&#160;&#160;Log display</string>
<string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string>
<string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<exclude domain="root" path="." />
</full-backup-content>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup disableIfNoEncryptionCapabilities="true">
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,43 @@
package se.ajpanton.notificationlog.capture
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import se.ajpanton.notificationlog.model.NotificationAction
class GroupSummaryPolicyTest {
private fun snapshot(isGroupSummary: Boolean) = NotificationSnapshot(
key = "key",
packageName = "example.app",
textContents = null,
hasImage = false,
isGroupSummary = isGroupSummary,
isRoutine = false,
)
@Test fun `enabled group-summary logging retains summaries`() {
assertTrue(GroupSummaryPolicy.shouldLog(snapshot(isGroupSummary = true), logGroupSummaries = true))
}
@Test fun `disabled group summaries are skipped without affecting children`() {
assertFalse(GroupSummaryPolicy.shouldLog(snapshot(isGroupSummary = true), logGroupSummaries = false))
assertTrue(GroupSummaryPolicy.shouldLog(snapshot(isGroupSummary = false), logGroupSummaries = false))
}
@Test fun `group summary cancellation is hidden when group summaries are disabled`() {
assertFalse(
GroupSummaryPolicy.shouldLog(
snapshot(isGroupSummary = false),
NotificationAction.GROUP_SUMMARY_CANCELLED,
logGroupSummaries = false,
),
)
assertTrue(
GroupSummaryPolicy.shouldLog(
snapshot(isGroupSummary = false),
NotificationAction.GROUP_SUMMARY_CANCELLED,
logGroupSummaries = true,
),
)
}
}
@@ -7,7 +7,7 @@ import org.junit.Test
class NotificationChangeClassifierTest {
private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot(
key = "key", packageName = "example.app", textContents = text, hasImage = false,
isRoutine = routine, imageBytes = null,
isGroupSummary = false, isRoutine = routine,
)
@Test fun `text change is an edit`() {
@@ -0,0 +1,32 @@
package se.ajpanton.notificationlog.capture
import org.junit.Assert.assertEquals
import org.junit.Test
class NotificationContentsTest {
@Test fun `expanded text replaces the alternate collapsed rendering`() {
val contents = NotificationContents.genericContents(
title = "Sender",
text = "Subject • Email body",
bigText = "Subject\nEmail body",
subText = "account@example.com",
summaryText = null,
textLines = emptyList(),
)
assertEquals("Sender\nSubject\nEmail body\naccount@example.com", contents)
}
@Test fun `collapsed text remains the fallback without expanded text`() {
val contents = NotificationContents.genericContents(
title = "Sender",
text = "Subject • Email body",
bigText = null,
subText = "account@example.com",
summaryText = null,
textLines = emptyList(),
)
assertEquals("Sender\nSubject • Email body\naccount@example.com", contents)
}
}
@@ -1,11 +1,15 @@
package se.ajpanton.notificationlog.export
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.TimestampClockFormat
import se.ajpanton.notificationlog.settings.TimestampDateFormat
import se.ajpanton.notificationlog.settings.TimestampZone
class LogExporterTest {
private val imageEntry = NotificationLogEntry(
@@ -30,4 +34,34 @@ class LogExporterTest {
assertFalse(withoutImage.contains("<img"))
assertTrue(withoutImage.contains("[image]"))
}
@Test fun `row writers produce the same export as the convenience methods`() {
val settings = LogViewSettings()
val csv = listOf(LogExporter.csvHeader(settings), LogExporter.csvRow(imageEntry, settings)).joinToString("\n")
val widths = LogExporter.headers(settings).map { it.length }.toMutableList()
LogExporter.values(imageEntry, settings).forEachIndexed { index, value ->
widths[index] = maxOf(widths[index], value.length)
}
val formatted = listOf(
LogExporter.formattedRow(LogExporter.headers(settings), widths),
LogExporter.formattedRow(LogExporter.values(imageEntry, settings), widths),
).joinToString("\n")
val html = LogExporter.htmlStart(settings) +
LogExporter.htmlRow(imageEntry, settings, "images/${imageEntry.imageId}.png") +
LogExporter.htmlEnd()
assertEquals(LogExporter.csv(listOf(imageEntry), settings), csv)
assertEquals(LogExporter.formatted(listOf(imageEntry), settings), formatted)
assertEquals(LogExporter.html(listOf(imageEntry), settings) { "images/${it.imageId}.png" }, html)
}
@Test fun `exports use the configured timestamp date and clock formats`() {
val settings = LogViewSettings(
timestampZone = TimestampZone.UTC,
timestampDateFormat = TimestampDateFormat.YEAR_MONTH_DAY,
timestampClockFormat = TimestampClockFormat.HOUR_24,
)
assertTrue(LogExporter.csv(listOf(imageEntry), settings).contains("1970-01-01 00:00:00"))
}
}
@@ -4,15 +4,72 @@ import org.junit.Assert.assertEquals
import org.junit.Test
class AppListOrderingTest {
private fun app(label: String, seen: Boolean = false, selected: Boolean = false) = ListedApp(label, "pkg.$label", seen, selected)
private fun app(
label: String,
seen: Boolean = false,
selected: Boolean = false,
hasEventOverride: Boolean = false,
) = ListedApp(label, "pkg.$label", seen, selected, hasEventOverride)
@Test fun `seen apps are first with a separator when requested`() {
val items = AppListOrdering.items(listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)), false, true)
assertEquals(listOf("Alpha", "Beta", "|", "Zulu"), items.map { if (it is AppListItem.App) it.value.label else "|" })
@Test fun `seen apps are titled and repeated before the full list when requested`() {
val items = AppListOrdering.items(
listOf(app("Zulu"), app("Beta", seen = true), app("Alpha", seen = true)),
onlySeen = false,
seenAppsFirst = true,
)
assertEquals(listOf("[Seen apps]", "Alpha", "Beta", "|", "[All apps]", "Alpha", "Beta", "Zulu"), items.labels())
}
@Test fun `selected unseen app remains after seen apps in only-seen mode`() {
val items = AppListOrdering.items(listOf(app("Seen", seen = true), app("Chosen", selected = true), app("Hidden")), true, true)
assertEquals(listOf("Seen", "|", "Chosen"), items.map { if (it is AppListItem.App) it.value.label else "|" })
@Test fun `selected unseen apps are titled after seen apps in only-seen mode`() {
val items = AppListOrdering.items(
listOf(app("Seen", seen = true), app("Chosen", selected = true), app("Hidden")),
onlySeen = true,
seenAppsFirst = true,
)
assertEquals(listOf("[Seen apps]", "Seen", "|", "[Edited apps]", "Chosen"), items.labels())
}
@Test fun `only seen and checked without grouping is one alphabetical list`() {
val items = AppListOrdering.items(
listOf(app("Zulu", seen = true), app("Beta", selected = true), app("Hidden")),
onlySeen = true,
seenAppsFirst = false,
)
assertEquals(listOf("Beta", "Zulu"), items.labels())
}
@Test fun `unseen app with an event override is included as edited`() {
val items = AppListOrdering.items(
listOf(app("Seen", seen = true), app("Configured", hasEventOverride = true), app("Hidden")),
onlySeen = true,
seenAppsFirst = true,
)
assertEquals(listOf("[Seen apps]", "Seen", "|", "[Edited apps]", "Configured"), items.labels())
}
@Test fun `only seen apps omit section titles without unseen checked apps`() {
val items = AppListOrdering.items(
listOf(app("Zulu", seen = true), app("Alpha", seen = true), app("Hidden")),
onlySeen = true,
seenAppsFirst = true,
)
assertEquals(listOf("Alpha", "Zulu"), items.labels())
}
@Test fun `all apps without grouping is one alphabetical list`() {
val items = AppListOrdering.items(
listOf(app("Zulu", seen = true), app("Alpha"), app("Beta", selected = true)),
onlySeen = false,
seenAppsFirst = false,
)
assertEquals(listOf("Alpha", "Beta", "Zulu"), items.labels())
}
private fun List<AppListItem>.labels() = map {
when (it) {
is AppListItem.App -> it.value.label
is AppListItem.SectionTitle -> "[${it.value}]"
AppListItem.Separator -> "|"
}
}
}
@@ -1,15 +0,0 @@
package se.ajpanton.notificationlog.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Test
class LoggingRuleCopyTest {
@Test fun `copy retains every rule field and owns its selected-package set`() {
val source = LoggingRule(false, AppRuleMode.WHITELIST, setOf("example.app"), true, false, false)
val target = source.copyForTarget()
assertEquals(source, target)
assertNotSame(source.selectedPackages, target.selectedPackages)
}
}
@@ -5,11 +5,28 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class NotificationListenerPolicyTest {
@Test fun `listener runs when any logging type is enabled`() {
assertTrue(NotificationListenerPolicy.shouldRun(listOf(LoggingRule(enabled = false), LoggingRule(enabled = true))))
@Test fun `listener runs when any event logging type is enabled`() {
assertTrue(NotificationListenerPolicy.shouldRun(rules(LoggingType.EDITS to true)))
}
@Test fun `listener stays off when every logging type is disabled`() {
assertFalse(NotificationListenerPolicy.shouldRun(LoggingType.entries.map { LoggingRule(enabled = false) }))
assertFalse(NotificationListenerPolicy.shouldRun(rules()))
}
@Test fun `content settings alone do not keep the listener active`() {
assertFalse(NotificationListenerPolicy.shouldRun(rules(LoggingType.TEXT_CONTENT to true, LoggingType.IMAGE_CONTENT to true)))
}
@Test fun `listener runs for an enabled per-app event override`() {
assertTrue(
NotificationListenerPolicy.shouldRun(
rules(),
hasEnabledEventOverride = true,
),
)
}
private fun rules(vararg enabled: Pair<LoggingType, Boolean>) = LoggingType.entries.associateWith { type ->
LoggingRule(enabled = enabled.firstOrNull { it.first == type }?.second ?: false)
}
}
@@ -6,22 +6,19 @@ import org.junit.Test
class NotificationRuleEvaluatorTest {
@Test fun `default blacklist allows every app`() {
assertTrue(NotificationRuleEvaluator.allows(LoggingRule(), "example.app"))
assertTrue(NotificationRuleEvaluator.allows(AppFilterSettings(), "example.app"))
}
@Test fun `blacklist excludes selected app`() {
val rule = LoggingRule(selectedPackages = setOf("example.app"))
val rule = AppFilterSettings(selectedPackages = setOf("example.app"))
assertFalse(NotificationRuleEvaluator.allows(rule, "example.app"))
assertTrue(NotificationRuleEvaluator.allows(rule, "other.app"))
}
@Test fun `whitelist accepts only selected app`() {
val rule = LoggingRule(appRuleMode = AppRuleMode.WHITELIST, selectedPackages = setOf("example.app"))
val rule = AppFilterSettings(mode = AppRuleMode.WHITELIST, selectedPackages = setOf("example.app"))
assertTrue(NotificationRuleEvaluator.allows(rule, "example.app"))
assertFalse(NotificationRuleEvaluator.allows(rule, "other.app"))
}
@Test fun `disabled rule rejects selected app`() {
assertFalse(NotificationRuleEvaluator.allows(LoggingRule(enabled = false), "example.app"))
}
}
+6
View File
@@ -6,6 +6,9 @@ coreKtx = "1.13.1"
fragmentKtx = "1.8.5"
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]
@@ -14,6 +17,9 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx"
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
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]
@@ -46,10 +46,12 @@ class MainActivity : AppCompatActivity() {
when (action) {
"post_text" -> postText("Text message")
"edit_text" -> postText("Edited message")
"messaging" -> postMessaging()
"messaging" -> postMessaging(updated = false)
"messaging_update" -> postMessaging(updated = true)
"image" -> postImage()
"dismissible" -> postDismissible()
"big_text" -> postBigText()
"very_long_text" -> postVeryLongText()
"inbox" -> postInbox()
"progress" -> postProgress(25)
"progress_update" -> postProgress(75)
@@ -65,12 +67,14 @@ class MainActivity : AppCompatActivity() {
private fun postText(text: String) = manager.notify(TEXT_ID, base().setContentTitle("Helper").setContentText(text).build())
private fun postMessaging() {
private fun postMessaging(updated: Boolean) {
val me = Person.Builder().setName("Me").build()
val alice = Person.Builder().setName("Alice").build()
val style = Notification.MessagingStyle(me)
.addMessage("Hello from Alice", 1, alice)
.addMessage("A reply from me", 2, null as Person?)
val style = Notification.MessagingStyle(me).addMessage("Hello from Alice", 1, alice)
if (updated) {
style.addMessage("Another message from Alice", 2, alice)
.addMessage("A reply from me", 3, null as Person?)
}
manager.notify(MESSAGING_ID, base().setSmallIcon(android.R.drawable.ic_dialog_email).setStyle(style).build())
}
@@ -83,6 +87,15 @@ class MainActivity : AppCompatActivity() {
private fun postBigText() = manager.notify(BIG_TEXT_ID, base().setStyle(Notification.BigTextStyle().bigText("A long notification body used to exercise Notification.EXTRA_BIG_TEXT.".repeat(3))).build())
private fun postVeryLongText() = manager.notify(
VERY_LONG_TEXT_ID,
base().setStyle(
Notification.BigTextStyle().bigText(
"A deliberately very long notification body used to exercise scrolling and collapse anchoring. ".repeat(180),
),
).build(),
)
private fun postInbox() = manager.notify(INBOX_ID, base().setStyle(Notification.InboxStyle().addLine("First inbox line").addLine("Second inbox line")).build())
private fun postProgress(progress: Int) = manager.notify(PROGRESS_ID, base().setContentTitle("Download").setContentText("$progress% complete").setOnlyAlertOnce(true).setProgress(100, progress, false).build())
@@ -105,6 +118,7 @@ class MainActivity : AppCompatActivity() {
const val MESSAGING_ID = 3
const val DISMISSIBLE_ID = 4
const val BIG_TEXT_ID = 5
const val VERY_LONG_TEXT_ID = 13
const val INBOX_ID = 6
const val PROGRESS_ID = 7
const val CHRONOMETER_ID = 8
@@ -115,7 +129,9 @@ class MainActivity : AppCompatActivity() {
const val GROUP_KEY = "helper-group"
val ACTIONS = listOf(
"Post text" to "post_text", "Edit text" to "edit_text", "Post messaging" to "messaging",
"Update messaging" to "messaging_update",
"Post image" to "image", "Post dismissible" to "dismissible", "Post big text" to "big_text",
"Post very long text" to "very_long_text",
"Post inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update",
"Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group",
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
+1 -1
View File
@@ -1 +1 @@
version=0.1
version=1.1