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, 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, 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. 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 ## Privacy
+3 -1
View File
@@ -88,7 +88,9 @@ dependencies {
implementation(libs.fragment.ktx) implementation(libs.fragment.ktx)
implementation(libs.material) implementation(libs.material)
implementation(libs.drawerlayout) implementation(libs.drawerlayout)
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0") implementation(libs.recyclerview)
implementation(libs.swiperefreshlayout)
implementation(libs.lifecycle.runtime.ktx)
testImplementation(libs.junit) testImplementation(libs.junit)
} }
+10 -2
View File
@@ -1,10 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?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 <application
android:name=".NotificationLogApplication"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@drawable/ic_launcher" android:icon="@drawable/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
@@ -1,149 +1,342 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationlog
import android.os.Bundle 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.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.ImageButton
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.StringRes import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment 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.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.AppListItem
import se.ajpanton.notificationlog.settings.AppListOrdering import se.ajpanton.notificationlog.settings.AppListOrdering
import se.ajpanton.notificationlog.settings.AppRuleMode
import se.ajpanton.notificationlog.settings.ListedApp 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 var binding: FragmentEventSettingsBinding? = null
private lateinit var type: LoggingType private lateinit var store: AppFilterSettingsStore
private lateinit var store: LoggingRuleStore private lateinit var perAppEventSettings: PerAppEventSettingsStore
private lateinit var appAdapter: AppListAdapter
private var appLoadGeneration = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentEventSettingsBinding.bind(view) binding = FragmentEventSettingsBinding.bind(view)
type = requireArguments().getSerializable(ARG_TYPE, LoggingType::class.java)!! store = AppFilterSettingsStore(requireContext())
store = LoggingRuleStore(requireContext()) perAppEventSettings = PerAppEventSettingsStore(requireContext())
binding!!.pageTitle.setText(requireArguments().getInt(ARG_TITLE)) appAdapter = AppListAdapter(::setPackageSelected, ::showPerAppEventSettings)
binding!!.appList.layoutManager = LinearLayoutManager(requireContext())
binding!!.appList.adapter = appAdapter
binding!!.appListRefresh.setOnRefreshListener(::reloadAppList)
installBottomRefresh()
bindRule() 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 var touchStartY = 0f
val scroll = binding!!.appScroll list.setOnTouchListener { _, event ->
scroll.setOnTouchListener { _, event ->
when (event.actionMasked) { when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> touchStartY = event.y MotionEvent.ACTION_DOWN -> touchStartY = event.y
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> if (!list.canScrollVertically(1) && event.y < touchStartY) reloadAppList()
val atBottom = scroll.scrollY >= (scroll.getChildAt(0).height - scroll.height).coerceAtLeast(0)
if (atBottom && event.y < touchStartY) {
refreshAppList()
}
}
} }
false false
} }
} }
override fun onDestroyView() { binding = null; super.onDestroyView() } private fun bindRule() {
val rule = store.load()
private fun refreshAppList() { listOf(
val currentBinding = binding ?: return binding!!.appRuleToggle,
currentBinding.appListRefresh.isRefreshing = true binding!!.onlySeenToggle,
currentBinding.appListRefresh.post { binding!!.seenFirstToggle,
if (binding === currentBinding) { ).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() reloadAppList()
}
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
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 currentBinding.appListRefresh.isRefreshing = false
} }
} }
} }
private fun bindRule() { private fun setPackageSelected(packageName: String, selected: Boolean) {
val rule = store.ruleFor(type) val current = store.load()
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 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 { val packages = current.selectedPackages.toMutableSet().apply {
if (checked) add(row.packageName) else remove(row.packageName) if (selected) add(packageName) else remove(packageName)
} }
store.save(type, current.copy(selectedPackages = packages)) store.save(current.copy(selectedPackages = packages))
// Deliberately do not reload: an unchecked unseen row must remain reachable. // Do not reload: an unchecked unseen row remains visible until the next requested refresh.
} }
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
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 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 orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) 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 }) 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)
} }
private fun separator() = View(requireContext()).apply { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1).apply { setMargins(0, 12, 0, 12) } val item = items[position]
setBackgroundColor(0x33000000) 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 { companion object {
private const val ARG_TITLE = "title" fun newInstance() = FilterAppsFragment()
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) }
}
} }
} }
@@ -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.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import android.view.WindowManager
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
@@ -17,28 +18,33 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.forEach
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
import androidx.fragment.app.commit import androidx.fragment.app.commit
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import se.ajpanton.notificationlog.databinding.ActivityMainBinding import se.ajpanton.notificationlog.databinding.ActivityMainBinding
import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.AppLockStore import se.ajpanton.notificationlog.settings.AppLockStore
import se.ajpanton.notificationlog.settings.LogAppFilterStore
class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener {
private lateinit var binding: ActivityMainBinding private lateinit var binding: ActivityMainBinding
private lateinit var drawerToggle: ActionBarDrawerToggle private lateinit var drawerToggle: ActionBarDrawerToggle
private var permanentSidebar = false private var permanentSidebar = false
private var needsUnlock = true private var needsUnlock = true
private lateinit var appLockStore: AppLockStore
private var currentItemId = R.id.nav_view_logs private var currentItemId = R.id.nav_view_logs
private var drawerNavigationBasePaddingLeft: Int? = null private var drawerNavigationBasePaddingLeft: Int? = null
private var permanentNavigationBasePaddingLeft: Int? = null private var permanentNavigationBasePaddingLeft: Int? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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. // Keep every app surface below the system bars rather than relying on edge-to-edge drawing.
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
binding = ActivityMainBinding.inflate(layoutInflater) binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
showLockedOverlay(appLockStore.enabled)
val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == val isNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK ==
Configuration.UI_MODE_NIGHT_YES Configuration.UI_MODE_NIGHT_YES
WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode WindowInsetsControllerCompat(window, binding.root).isAppearanceLightNavigationBars = !isNightMode
@@ -84,12 +90,20 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (needsUnlock && AppLockStore(this).enabled) requestUnlock() if (needsUnlock && appLockStore.enabled) {
showLockedOverlay(true)
requestUnlock()
} else {
showLockedOverlay(false)
}
} }
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (!isChangingConfigurations) needsUnlock = true if (!isChangingConfigurations) {
needsUnlock = true
if (appLockStore.enabled) showLockedOverlay(true)
}
} }
private fun requestUnlock() { private fun requestUnlock() {
@@ -98,15 +112,31 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
.setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL) .setAllowedAuthenticators(Authenticators.BIOMETRIC_STRONG or Authenticators.DEVICE_CREDENTIAL)
.build() .build()
.authenticate(CancellationSignal(), mainExecutor, object : BiometricPrompt.AuthenticationCallback() { .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() } override fun onAuthenticationError(errorCode: Int, errString: CharSequence?) { finishAndRemoveTask() }
}) })
} }
override fun onNavigationItemSelected(item: MenuItem): Boolean { private fun showLockedOverlay(visible: Boolean) {
if (item.itemId == R.id.nav_settings_header) { binding.lockOverlay.visibility = if (visible) View.VISIBLE else View.GONE
return false
} }
/** Called immediately when the Settings switch changes, before a task snapshot can be captured. */
fun setAppLockEnabled(enabled: Boolean) {
appLockStore.enabled = enabled
updateSensitiveWindowPolicy(enabled)
}
private fun updateSensitiveWindowPolicy(locked: Boolean) {
setRecentsScreenshotEnabled(!locked)
if (locked) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
override fun onNavigationItemSelected(item: MenuItem): Boolean {
navigateTo(item.itemId) navigateTo(item.itemId)
if (!permanentSidebar) { if (!permanentSidebar) {
binding.drawerLayout.closeDrawer(GravityCompat.START) binding.drawerLayout.closeDrawer(GravityCompat.START)
@@ -124,13 +154,16 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
when { when {
page == Page.VIEW_LOGS -> ViewLogsFragment() page == Page.VIEW_LOGS -> ViewLogsFragment()
page == Page.SETTINGS -> SettingsFragment() 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) else -> PageFragment.newInstance(page.titleRes)
}, },
) )
runOnCommit(::applyPageTitleVisibility) runOnCommit(::applyPageTitleVisibility)
} }
title = getString(page.titleRes) title = getString(page.titleRes)
invalidateOptionsMenu()
} }
private fun setupNavigationView(navigationView: NavigationView) { private fun setupNavigationView(navigationView: NavigationView) {
@@ -147,15 +180,10 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private fun syncNavigationSelection(navigationView: NavigationView, itemId: Int) { private fun syncNavigationSelection(navigationView: NavigationView, itemId: Int) {
val menu = navigationView.menu ?: return val menu = navigationView.menu ?: return
for (index in 0 until menu.size()) { menu.forEach { item ->
val item = menu.getItem(index)
if (item.itemId == R.id.nav_settings_header) {
item.isCheckable = false
} else {
item.isCheckable = true item.isCheckable = true
item.isChecked = item.itemId == itemId item.isChecked = item.itemId == itemId
} }
}
navigationView.invalidate() 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() { private fun applyPageTitleVisibility() {
(supportFragmentManager.findFragmentById(R.id.content_frame) as? PageFragment) (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) 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( private enum class Page(
val menuId: Int, val menuId: Int,
val titleRes: Int, val titleRes: Int,
val loggingType: LoggingType? = null,
) { ) {
VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs), VIEW_LOGS(R.id.nav_view_logs, R.string.page_view_logs),
SETTINGS(R.id.nav_settings, R.string.page_settings), SETTINGS(R.id.nav_settings, R.string.page_settings),
APPEARING(R.id.nav_appearing, R.string.page_appearing, LoggingType.APPEARING), LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display),
DISAPPEARING(R.id.nav_disappearing, R.string.page_disappearing, LoggingType.DISAPPEARING), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging),
TEXT_CONTENT(R.id.nav_text_content, R.string.page_text_content, LoggingType.TEXT_CONTENT), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps),
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),
; ;
companion object { companion object {
@@ -296,6 +357,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
private companion object { private companion object {
const val STATE_CURRENT_ITEM_ID = "current_item_id" const val STATE_CURRENT_ITEM_ID = "current_item_id"
const val MENU_LOG_FILTER = 1
const val PERMANENT_SIDEBAR_FOLDED_WIDTH_MULTIPLIER = 1.1f 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.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.util.AttributeSet import android.util.AttributeSet
import androidx.core.graphics.withClip
import androidx.drawerlayout.widget.DrawerLayout import androidx.drawerlayout.widget.DrawerLayout
/** /**
@@ -33,9 +34,8 @@ class SafeInsetDrawerLayout @JvmOverloads constructor(
super.dispatchDraw(canvas) super.dispatchDraw(canvas)
return return
} }
val saveCount = canvas.save() canvas.withClip(safeInsetLeft, 0, (width - safeInsetRight).coerceAtLeast(safeInsetLeft), height) {
canvas.clipRect(safeInsetLeft, 0, (width - safeInsetRight).coerceAtLeast(safeInsetLeft), height) super.dispatchDraw(this)
super.dispatchDraw(canvas) }
canvas.restoreToCount(saveCount)
} }
} }
@@ -1,28 +1,32 @@
package se.ajpanton.notificationlog package se.ajpanton.notificationlog
import android.os.Bundle import android.os.Bundle
import android.content.ClipData
import android.net.Uri import android.net.Uri
import android.content.ComponentName
import android.content.Intent
import android.provider.Settings
import android.view.View import android.view.View
import android.widget.ArrayAdapter
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.switchmaterial.SwitchMaterial import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding import se.ajpanton.notificationlog.databinding.FragmentSettingsBinding
import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettingsStore 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.settings.AppLockStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.EncryptedImageStore import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.export.LogExporter 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 java.util.zip.ZipOutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsFragment : Fragment(R.layout.fragment_settings) { class SettingsFragment : Fragment(R.layout.fragment_settings) {
private var binding: FragmentSettingsBinding? = null private var binding: FragmentSettingsBinding? = null
private var selectedCopyTargets = emptySet<LoggingType>()
private var pendingExport: ExportFormat? = null private var pendingExport: ExportFormat? = null
private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> private val createDocument = registerForActivityResult(androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
uri?.let(::writeExport) uri?.let(::writeExport)
@@ -30,147 +34,78 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentSettingsBinding.bind(view) 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!!.exportLogs.setOnClickListener { confirmExport() }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() } binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
val lockStore = AppLockStore(requireContext()) val lockStore = AppLockStore(requireContext())
binding!!.appLock.isChecked = lockStore.enabled 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 onDestroyView() { binding = null; super.onDestroyView() }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
refreshEventMasterToggles() binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access"
} }
private fun setupEventMasterToggles() { private fun isListenerEnabled(): Boolean = requireContext().getSystemService(android.app.NotificationManager::class.java)
val container = binding!!.eventMasterToggles .isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
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 refreshEventMasterToggles() { private fun bindStorageLimits() {
val store = LoggingRuleStore(requireContext()) val store = StorageLimitsStore(requireContext())
val container = binding?.eventMasterToggles ?: return val limits = store.load()
for (index in 0 until container.childCount) { binding!!.logLimitMib.setText((limits.logBytes / StorageLimitsStore.MEBIBYTE).toString())
val toggle = container.getChildAt(index) as SwitchMaterial binding!!.imageLimitMib.setText((limits.imageBytes / StorageLimitsStore.MEBIBYTE).toString())
toggle.setOnCheckedChangeListener(null) binding!!.saveStorageLimits.setOnClickListener {
toggle.isChecked = store.ruleFor(toggle.tag as LoggingType).enabled val logLimit = binding!!.logLimitMib.text.toString().toLongOrNull()
toggle.setOnCheckedChangeListener { _, enabled -> val imageLimit = binding!!.imageLimitMib.text.toString().toLongOrNull()
val type = toggle.tag as LoggingType if (logLimit == null || imageLimit == null ||
store.save(type, store.ruleFor(type).copy(enabled = enabled)) 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() { private fun confirmClearLogs() {
val labels = listOf("From") + LoggingType.entries.map { it.label() } val filterStore = LogAppFilterStore(requireContext())
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 }
val dialog = MaterialAlertDialogBuilder(requireContext()) val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Copy ${source.label()} settings to") .setTitle("Clear logs?")
.setMultiChoiceItems(targets.map { it.label() }.toTypedArray(), checked) { _, index, selected -> checked[index] = selected } .setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
.setNeutralButton("All", null) .setPositiveButton("Clear") { _, _ ->
.setPositiveButton("Done") { _, _ -> clearLogs()
selectedCopyTargets = targets.filterIndexed { index, _ -> checked[index] }.toSet()
updateCopyButton()
}
.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() dialog.show()
} }
private fun updateCopyButton() { binding?.copyEventSettings?.isEnabled = selectedCopyTargets.isNotEmpty() } private fun clearLogs() {
val appContext = requireContext().applicationContext
private fun confirmClearLogs() { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
MaterialAlertDialogBuilder(requireContext()) EncryptedNotificationLogStore(appContext).clear()
.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 chooseExportFormat() { private fun chooseExportFormat() {
MaterialAlertDialogBuilder(requireContext()) 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] pendingExport = ExportFormat.entries[which]
createDocument.launch("notification-log.${pendingExport!!.extension}") createDocument.launch("notification-log.${pendingExport!!.extension}")
} }
@@ -189,36 +124,94 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
private fun writeExport(uri: Uri) { private fun writeExport(uri: Uri) {
val format = pendingExport ?: return val format = pendingExport ?: return
val context = requireContext().applicationContext val context = requireContext().applicationContext
Thread { viewLifecycleOwner.lifecycleScope.launch {
val entries = EncryptedNotificationLogStore(context).readAll() val result = runCatching {
withContext(Dispatchers.IO) {
val logStore = EncryptedNotificationLogStore(context)
val settings = LogViewSettingsStore(context).load() val settings = LogViewSettingsStore(context).load()
context.contentResolver.openOutputStream(uri)?.use { output -> context.contentResolver.openOutputStream(uri)?.use { output ->
when (format) { when (format) {
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray()) ExportFormat.CSV -> writeCsv(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context)
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray()) ExportFormat.FORMATTED -> writeFormatted(output.bufferedWriter(Charsets.UTF_8), logStore, settings, context)
ExportFormat.HTML_ZIP -> ZipOutputStream(output).use { zip -> ExportFormat.HTML_ZIP -> writeHtmlZip(ZipOutputStream(output), logStore, settings, context)
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()
} }
} }
} }
} }
}.start() pendingExport = null
result.exceptionOrNull()?.let { error ->
android.widget.Toast.makeText(requireContext(), "Export failed: ${error.message ?: "unknown error"}", android.widget.Toast.LENGTH_LONG).show()
}
}
} }
private fun LoggingType.label() = name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase) 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()
}
}
}
}
}
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") } 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 package se.ajpanton.notificationlog
import android.content.ComponentName import android.animation.Animator
import android.content.Intent import android.animation.AnimatorListenerAdapter
import android.animation.ArgbEvaluator
import android.animation.ValueAnimator
import android.graphics.Color
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.net.Uri import android.graphics.Paint
import android.os.Bundle 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.text.TextUtils
import android.view.Gravity
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import se.ajpanton.notificationlog.capture.NotificationCaptureService
import se.ajpanton.notificationlog.data.EncryptedImageStore import se.ajpanton.notificationlog.data.EncryptedImageStore
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
import se.ajpanton.notificationlog.data.NewestLogCursor
import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding import se.ajpanton.notificationlog.databinding.FragmentViewLogsBinding
import se.ajpanton.notificationlog.export.LogExporter
import se.ajpanton.notificationlog.model.NotificationAction import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.LogViewSettingsStore import se.ajpanton.notificationlog.settings.LogViewSettingsStore
import se.ajpanton.notificationlog.settings.TimestampZone import se.ajpanton.notificationlog.settings.LogAppFilterStore
import java.text.DateFormat import se.ajpanton.notificationlog.settings.TimestampFormatter
import java.util.Date import se.ajpanton.notificationlog.settings.DisplayEvent
import java.util.zip.ZipOutputStream 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) { class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var binding: FragmentViewLogsBinding? = null private var binding: FragmentViewLogsBinding? = null
private var pendingExport: ExportFormat? = null
private val expandedIds = mutableSetOf<String>() private val expandedIds = mutableSetOf<String>()
private val createDocument = registerForActivityResult( private val rows = mutableListOf<LogRow>()
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("application/octet-stream"), private var nextCursor: NewestLogCursor? = null
) { uri -> uri?.let(::writeExport) } 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?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
binding = FragmentViewLogsBinding.bind(view) binding = FragmentViewLogsBinding.bind(view)
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } appFilterStore = LogAppFilterStore(requireContext())
binding!!.exportLogs.setOnClickListener { confirmExport() } binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
binding!!.clearLogs.setOnClickListener { confirmClearLogs() } binding!!.logList.layoutManager = LinearLayoutManager(requireContext())
loadLogs() 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() { override fun onResume() {
super.onResume() super.onResume()
binding?.notificationAccess?.text = if (isListenerEnabled()) "Notification access enabled" else "Enable notification access" loadLogs(reset = true)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -55,99 +85,275 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
super.onDestroyView() super.onDestroyView()
} }
private fun loadLogs() { private fun loadLogs(reset: Boolean) {
if (loading && !reset) return
if (!reset && noMoreRows) return
val context = requireContext().applicationContext val context = requireContext().applicationContext
Thread { if (reset) {
val imageStore = EncryptedImageStore(context) rows.clear()
val rows = EncryptedNotificationLogStore(context).readAll() nextCursor = null
.sortedByDescending { it.recordedAtEpochMillis } noMoreRows = false
.map { entry -> LogRow(entry, entry.imageId?.let(imageStore::read)) } loadGeneration++
activity?.runOnUiThread { binding?.let { render(it, rows) } } }
}.start() 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>) { private fun render(view: FragmentViewLogsBinding) {
view.logRows.removeAllViews()
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE
val settings = LogViewSettingsStore(requireContext()).load() 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 { /** Called by MainActivity's header action. Selections persist until Reset is chosen. */
orientation = LinearLayout.VERTICAL 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 { 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) { fun renderExpanded(expanded: Boolean) {
removeAllViews() removeAllViews()
if (!expanded) { val metadata = metadataValues(row.entry, settings)
addView(TextView(context).apply { val contents = fieldValue(LogField.CONTENTS, row.entry, settings)
text = values(row.entry, settings).joinToString(" · ") val hasContents = LogField.CONTENTS in settings.visibleFields && !contents.isNullOrEmpty()
maxLines = 1 val toggleExpanded = {
ellipsize = TextUtils.TruncateAt.END val collapsing = expanded
setPadding(0, dp(8), 0, dp(8)) val collapseAnchor = if (collapsing) captureCollapseAnchor(this@apply) else null
}) if (!expandedIds.add(row.entry.id)) expandedIds.remove(row.entry.id)
return renderExpanded(row.entry.id in expandedIds)
if (collapsing) {
val highlight = { highlightCollapsedRow(this@apply) }
if (collapseAnchor != null) {
restoreCollapseAnchor(collapseAnchor, highlight)
} else {
this@apply.doOnPreDraw { highlight() }
} }
settings.order.filter { it in settings.visibleFields }.forEach { field -> }
val value = fieldValue(field, row.entry, settings).takeUnless { field == LogField.CONTENTS && it == "[image]" } Unit
if (!value.isNullOrEmpty()) { }
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 (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)
setOnLongClickListener {
showDeleteDialog(row.entry.id)
}
}
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 { addView(TextView(context).apply {
text = value text = if (expanded && value.breakBeforePeriods) value.text.replace(".", "\u200B.") else value.text
setLineSpacing(0f, 0.92f) maxLines = if (expanded) Int.MAX_VALUE else 1
setPadding(0, dp(1), 0, dp(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) }
} }
if (row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
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 { addView(TextView(context).apply {
text = "Previous: ${row.entry.previousContents}" text = row.entry.previousContents
setLineSpacing(0f, 0.92f) setLineSpacing(0f, 1f)
setPadding(0, dp(1), 0, dp(1))
}) })
} }
row.imageBytes?.let { bytes -> if (expanded && row.entry.imageId != null) {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.let { bitmap -> val imageId = row.entry.imageId
addView(ImageView(context).apply { 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) setImageBitmap(bitmap)
adjustViewBounds = true adjustViewBounds = true
maxHeight = dp(240) maxHeight = dp(240)
contentDescription = "Notification image" contentDescription = "Notification image"
setPadding(0, dp(4), 0, dp(4)) setPadding(0, dp(4), 0, 0)
setOnClickListener {
if (isAdded) {
ImageViewerDialogFragment.newInstance(imageId).show(parentFragmentManager, IMAGE_VIEWER_TAG)
}
}
}) })
} }
} }
} }
setOnClickListener { onToggleExpanded() }
renderExpanded(row.entry.id in expandedIds) setOnLongClickListener { showDeleteDialog(row.entry.id) }
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
}
} }
private fun values(entry: NotificationLogEntry, settings: LogViewSettings): List<String> = private fun previousDivider(): LinearLayout = LinearLayout(requireContext()).apply {
settings.order.filter { it in settings.visibleFields }.mapNotNull { fieldValue(it, entry, settings) } 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) { 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.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> actionLabel(entry.action) 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) { private fun actionLabel(action: NotificationAction): String = when (action) {
NotificationAction.APP_CANCELLED -> "App cancelled notification" NotificationAction.APPEARED -> "Appeared"
NotificationAction.APP_CANCELLED_ALL -> "App cancelled all notifications" NotificationAction.ALREADY_ACTIVE -> "Already active"
NotificationAction.USER_DISMISSED -> "User dismissed notification" NotificationAction.EDITED -> "Edited"
NotificationAction.USER_DISMISSED_ALL -> "User dismissed all notifications" 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) else -> action.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase)
} }
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply { private fun eventFor(action: NotificationAction): DisplayEvent = when (action) {
timeZone = when (zone) { NotificationAction.APPEARED, NotificationAction.ALREADY_ACTIVE -> DisplayEvent.APPEARING
TimestampZone.UTC -> java.util.TimeZone.getTimeZone("UTC") NotificationAction.EDITED -> DisplayEvent.EDITS
TimestampZone.LOCAL_NOW -> java.util.TimeZone.getDefault() else -> DisplayEvent.DISAPPEARING
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(java.util.TimeZone::getTimeZone) ?: java.util.TimeZone.getDefault()
} }
}.format(Date(value))
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)) .isNotificationListenerAccessGranted(ComponentName(requireContext(), NotificationCaptureService::class.java))
private fun confirmClearLogs() { 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 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 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 inner class LogAdapter : RecyclerView.Adapter<LogAdapter.Holder>() {
private var rows: List<LogRow> = emptyList()
private var settings = LogViewSettings()
private var metadataWidth = 0
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()) MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?") .setTitle("Delete this log?")
.setMessage("This permanently removes every stored notification log and copied image.") .setMessage("This permanently removes this log and its copied image, if any.")
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ -> .setPositiveButton("Delete") { _, _ ->
EncryptedNotificationLogStore(requireContext()).clear() viewLifecycleOwner.lifecycleScope.launch {
expandedIds.clear() withContext(Dispatchers.IO) {
loadLogs() EncryptedNotificationLogStore(requireContext().applicationContext).delete(entryId)
}
loadLogs(reset = true)
}
} }
.show() .show()
return true
} }
private fun confirmExport() { private fun captureCollapseAnchor(collapsingRow: View): CollapseAnchor? {
MaterialAlertDialogBuilder(requireContext()) val currentBinding = binding ?: return null
.setTitle("Export unencrypted logs?") val list = currentBinding.logList
.setMessage("The exported file will not be encrypted. Anyone with access to its destination can read the selected log fields.") val manager = list.layoutManager as? LinearLayoutManager ?: return null
.setNegativeButton("Cancel", null) val collapsingItem = list.findContainingItemView(collapsingRow) ?: return null
.setPositiveButton("Continue") { _, _ -> chooseExportFormat() } val collapsingPosition = list.getChildAdapterPosition(collapsingItem)
.show() 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 chooseExportFormat() { private fun restoreCollapseAnchor(anchor: CollapseAnchor, afterRestore: () -> Unit) {
MaterialAlertDialogBuilder(requireContext()) val currentBinding = binding ?: return
.setItems(arrayOf("CSV", "Formatted text", "HTML ZIP")) { _, which -> val list = currentBinding.logList
pendingExport = ExportFormat.entries[which] val manager = list.layoutManager as? LinearLayoutManager ?: return
createDocument.launch("notification-log.${pendingExport!!.extension}") 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()
} }
.show()
} }
private fun writeExport(uri: Uri) { private fun highlightCollapsedRow(row: View) {
val format = pendingExport ?: return if (!row.isAttachedToWindow) return
val context = requireContext().applicationContext val baseColor = ContextCompat.getColor(row.context, R.color.primary)
Thread { val startColor = Color.argb(
val entries = EncryptedNotificationLogStore(context).readAll() COLLAPSE_HIGHLIGHT_ALPHA,
val settings = LogViewSettingsStore(context).load() Color.red(baseColor),
context.contentResolver.openOutputStream(uri)?.use { output -> Color.green(baseColor),
when (format) { Color.blue(baseColor),
ExportFormat.CSV -> output.write(LogExporter.csv(entries, settings).encodeToByteArray()) )
ExportFormat.FORMATTED -> output.write(LogExporter.formatted(entries, settings).encodeToByteArray()) val endColor = Color.argb(0, Color.red(baseColor), Color.green(baseColor), Color.blue(baseColor))
ExportFormat.HTML_ZIP -> writeHtmlExport(output, entries, settings, context) 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()
} }
}.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 dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() 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, val imageBytes: ByteArray?) private data class LogRow(val entry: NotificationLogEntry)
private enum class ExportFormat(val extension: String) { CSV("csv"), FORMATTED("txt"), HTML_ZIP("zip") } 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.LoggingRuleStore
import se.ajpanton.notificationlog.settings.LoggingType import se.ajpanton.notificationlog.settings.LoggingType
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator 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.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -20,12 +23,18 @@ class NotificationCaptureService : NotificationListenerService() {
private lateinit var logStore: EncryptedNotificationLogStore private lateinit var logStore: EncryptedNotificationLogStore
private lateinit var writeExecutor: ExecutorService private lateinit var writeExecutor: ExecutorService
private lateinit var ruleStore: LoggingRuleStore 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 private lateinit var imageStore: EncryptedImageStore
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
logStore = EncryptedNotificationLogStore(this) logStore = EncryptedNotificationLogStore(this)
ruleStore = LoggingRuleStore(this) ruleStore = LoggingRuleStore(this)
captureSettings = CaptureSettingsStore(this)
appFilterStore = AppFilterSettingsStore(this)
perAppEventSettings = PerAppEventSettingsStore(this)
imageStore = EncryptedImageStore(this) imageStore = EncryptedImageStore(this)
writeExecutor = Executors.newSingleThreadExecutor { runnable -> writeExecutor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "notification-log-writer") Thread(runnable, "notification-log-writer")
@@ -35,22 +44,41 @@ class NotificationCaptureService : NotificationListenerService() {
override fun onListenerConnected() { override fun onListenerConnected() {
super.onListenerConnected() super.onListenerConnected()
getActiveNotifications()?.forEach { sbn -> getActiveNotifications()?.forEach { sbn ->
val snapshot = NotificationContents.snapshot(sbn, this) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
activeNotifications[snapshot.key] = snapshot 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) { override fun onNotificationPosted(sbn: StatusBarNotification) {
val snapshot = NotificationContents.snapshot(sbn, this) val snapshot = NotificationContents.snapshot(sbn)
SeenApps.markSeen(snapshot.packageName) SeenApps.markSeen(snapshot.packageName)
val previous = activeNotifications.put(snapshot.key, snapshot) val previous = activeNotifications.put(snapshot.key, snapshot)
when { 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.isMeaningfulEdit(previous, snapshot) &&
!NotificationChangeClassifier.shouldIgnoreEdit(previous, snapshot, ruleStore.ruleFor(LoggingType.EDITS).ignoreRoutineUpdates) -> !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, rankingMap: RankingMap,
reason: Int, 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) SeenApps.markSeen(snapshot.packageName)
record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false) record(snapshot, actionForRemoval(reason), LoggingType.DISAPPEARING, includeContents = false)
} }
@@ -75,11 +103,13 @@ class NotificationCaptureService : NotificationListenerService() {
loggingType: LoggingType, loggingType: LoggingType,
includeContents: Boolean, includeContents: Boolean,
previousSnapshot: NotificationSnapshot? = null, 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 appName = appName(snapshot.packageName)
val retainImage = includeContents && snapshot.imageBytes != null && val retainImage = includeContents && snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
NotificationRuleEvaluator.allows(ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName) val retainedImageBytes = if (retainImage) imageBytes?.invoke() else null
val entry = NotificationLogEntry( val entry = NotificationLogEntry(
recordedAtEpochMillis = System.currentTimeMillis(), recordedAtEpochMillis = System.currentTimeMillis(),
eventTimeZoneId = java.util.TimeZone.getDefault().id, eventTimeZoneId = java.util.TimeZone.getDefault().id,
@@ -88,13 +118,13 @@ class NotificationCaptureService : NotificationListenerService() {
action = action, action = action,
contents = if (includeContents) visibleContents(snapshot) else null, contents = if (includeContents) visibleContents(snapshot) else null,
previousContents = previousSnapshot?.let(::visibleContents), 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 { writeExecutor.execute {
try { try {
if (retainImage) { if (retainedImageBytes != null) {
try { try {
imageStore.save(entry.imageId!!, snapshot.imageBytes!!) imageStore.save(entry.imageId!!, retainedImageBytes)
} catch (error: Exception) { } catch (error: Exception) {
Log.w(TAG, "Could not retain notification image; keeping the text event", error) Log.w(TAG, "Could not retain notification image; keeping the text event", error)
logStore.append(entry.copy(imageId = null)) logStore.append(entry.copy(imageId = null))
@@ -111,17 +141,25 @@ class NotificationCaptureService : NotificationListenerService() {
private fun visibleContents(snapshot: NotificationSnapshot): String? { private fun visibleContents(snapshot: NotificationSnapshot): String? {
val text = snapshot.textContents?.takeIf { 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( val image = snapshot.hasImage && allows(LoggingType.IMAGE_CONTENT, snapshot.packageName)
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
)
return listOfNotNull(text, if (image) "[image]" else null) return listOfNotNull(text, if (image) "[image]" else null)
.joinToString("") .joinToString("\n")
.take(MAX_CONTENT_CHARACTERS) .take(MAX_CONTENT_CHARACTERS)
.ifEmpty { null } .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 { private fun appName(packageName: String): String = try {
val applicationInfo = packageManager.getApplicationInfo(packageName, 0) val applicationInfo = packageManager.getApplicationInfo(packageName, 0)
packageManager.getApplicationLabel(applicationInfo).toString() packageManager.getApplicationLabel(applicationInfo).toString()
@@ -9,39 +9,66 @@ import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Icon import android.graphics.drawable.Icon
import android.os.Bundle import android.os.Bundle
import android.service.notification.StatusBarNotification import android.service.notification.StatusBarNotification
import androidx.core.graphics.createBitmap
import androidx.core.graphics.scale
data class NotificationSnapshot( data class NotificationSnapshot(
val key: String, val key: String,
val packageName: String, val packageName: String,
val textContents: String?, val textContents: String?,
val hasImage: Boolean, val hasImage: Boolean,
val isGroupSummary: Boolean,
val isRoutine: Boolean, val isRoutine: Boolean,
val imageBytes: ByteArray?,
) )
object NotificationContents { object NotificationContents {
fun extract(notification: Notification): String? { fun extract(notification: Notification): String? {
val parts = linkedSetOf<String>()
val extras = notification.extras ?: Bundle.EMPTY val extras = notification.extras ?: Bundle.EMPTY
extras.getCharSequence(Notification.EXTRA_TITLE)?.addTo(parts) messagingContents(extras)?.let { return it }
extras.getCharSequence(Notification.EXTRA_TEXT)?.addTo(parts) return genericContents(
extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.addTo(parts) title = extras.getCharSequence(Notification.EXTRA_TITLE),
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.addTo(parts) text = extras.getCharSequence(Notification.EXTRA_TEXT),
extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)?.addTo(parts) bigText = extras.getCharSequence(Notification.EXTRA_BIG_TEXT),
extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.forEach { it?.addTo(parts) } subText = extras.getCharSequence(Notification.EXTRA_SUB_TEXT),
summaryText = extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT),
textLines = extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES).orEmpty().asList(),
)
}
/** 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( Notification.MessagingStyle.Message.getMessagesFromBundleArray(
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java), extras.getParcelableArray(key, Bundle::class.java),
).forEach { message -> ).forEach { message ->
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts) listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
} }
return parts.takeIf { it.isNotEmpty() }?.joinToString("") }
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
} }
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot { fun snapshot(sbn: StatusBarNotification): NotificationSnapshot {
val notification = sbn.notification val notification = sbn.notification
val extras = notification.extras 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( return NotificationSnapshot(
key = sbn.key, key = sbn.key,
packageName = sbn.packageName, packageName = sbn.packageName,
@@ -49,12 +76,24 @@ object NotificationContents {
hasImage = extras?.let { hasImage = extras?.let {
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON) it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
} == true, } == true,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true || isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
extras?.containsKey(Notification.EXTRA_PROGRESS) == true, extras?.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) == true ||
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() }, 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) { private fun Drawable.toBitmap(): Bitmap? = when (this) {
is BitmapDrawable -> bitmap is BitmapDrawable -> bitmap
else -> runCatching { else -> runCatching {
@@ -63,7 +102,7 @@ object NotificationContents {
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(sourceWidth, sourceHeight)) val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(sourceWidth, sourceHeight))
val width = (sourceWidth * scale).toInt() val width = (sourceWidth * scale).toInt()
val height = (sourceHeight * 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) setBounds(0, 0, width, height)
draw(Canvas(bitmap)) draw(Canvas(bitmap))
} }
@@ -71,8 +110,8 @@ object NotificationContents {
} }
private fun Bitmap.toPng(): ByteArray? = runCatching { private fun Bitmap.toPng(): ByteArray? = runCatching {
val scale = minOf(1f, MAX_IMAGE_DIMENSION.toFloat() / maxOf(width, height)) val scaleFactor = 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 bitmap = if (scaleFactor < 1f) scale((width * scaleFactor).toInt(), (height * scaleFactor).toInt()) else this
java.io.ByteArrayOutputStream().use { output -> java.io.ByteArrayOutputStream().use { output ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, output) bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
output.toByteArray() output.toByteArray()
@@ -83,5 +122,7 @@ object NotificationContents {
toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add) toString().trim().takeIf { it.isNotEmpty() }?.let(parts::add)
} }
private fun CharSequence?.hasText(): Boolean = !this.isNullOrBlank()
private const val MAX_IMAGE_DIMENSION = 1600 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) { fun delete(id: String) {
require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." } require(id.matches(IMAGE_ID_PATTERN)) { "Invalid notification image ID." }
File(directory, "$id.bin").delete() File(directory, "$id.bin").delete()
@@ -3,55 +3,210 @@ package se.ajpanton.notificationlog.data
import android.content.Context import android.content.Context
import android.util.AtomicFile import android.util.AtomicFile
import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.StorageLimitsStore
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.DataInputStream import java.io.DataInputStream
import java.io.DataOutputStream import java.io.DataOutputStream
import java.io.FileNotFoundException
import java.io.File 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 * Independently encrypted log chunks. Reading or appending a record needs only
* encrypted, including timestamps and package names, while the app is at rest. * one bounded chunk; older chunks stay on disk until requested by the viewer.
*/ */
class EncryptedNotificationLogStore(context: Context) { class EncryptedNotificationLogStore(context: Context) {
private val lock = Any() private val appContext = context.applicationContext
private val file = AtomicFile(context.filesDir.resolve(FILE_NAME)) private val directory = File(appContext.filesDir, DIRECTORY_NAME).also(File::mkdirs)
private val imageDirectory = File(context.filesDir, IMAGE_DIRECTORY_NAME) private val imageDirectory = File(appContext.filesDir, IMAGE_DIRECTORY_NAME)
private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate()) private val cipher = AesGcmCipher(LogEncryptionKeyProvider().getOrCreate())
fun readAll(): List<NotificationLogEntry> = synchronized(lock) { fun readNewest(cursor: NewestLogCursor?, limit: Int): NotificationLogPage = synchronized(lock) {
val payload = readPayloadOrNull() ?: return emptyList() require(limit > 0)
NotificationLogEntryJson.decode(cipher.decrypt(payload)) 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) { fun append(entry: NotificationLogEntry) = synchronized(lock) {
val entries = (readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: emptyList()) val chunks = chunkFiles()
val retained = retain(entries + entry) val newest = chunks.lastOrNull()
write(retained) val current = newest?.let(::readChunk).orEmpty()
(entries.mapNotNull { it.imageId } - retained.mapNotNull { it.imageId }.toSet()).forEach(::deleteImage) if (newest == null || encodedSize(current + entry) > MAX_CHUNK_PLAINTEXT_BYTES) {
writeChunk(nextChunkFile(chunks), listOf(entry))
} else {
writeChunk(newest, current + entry)
} }
enforceLimits()
fun replaceAll(entries: List<NotificationLogEntry>) = synchronized(lock) {
write(entries)
} }
fun clear() = synchronized(lock) { fun clear() = synchronized(lock) {
file.delete() clearChunksOnly()
imageDirectory.listFiles()?.forEach(File::delete) imageDirectory.listFiles()?.forEach(File::delete)
} }
fun delete(id: String): Boolean = synchronized(lock) { fun delete(id: String): Boolean = synchronized(lock) {
val entries = readPayloadOrNull()?.let { NotificationLogEntryJson.decode(cipher.decrypt(it)) } ?: return false chunkFiles().forEach { chunk ->
val entry = entries.firstOrNull { it.id == id } ?: return false val entries = readChunk(chunk)
write(entries.filterNot { it.id == id }) 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) entry.imageId?.let(::deleteImage)
true 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 payload = cipher.encrypt(NotificationLogEntryJson.encode(entries))
val output = file.startWrite() val atomicFile = AtomicFile(file)
val output = atomicFile.startWrite()
try { try {
val stream = DataOutputStream(BufferedOutputStream(output)) val stream = DataOutputStream(BufferedOutputStream(output))
stream.writeInt(FILE_VERSION) stream.writeInt(FILE_VERSION)
@@ -60,56 +215,40 @@ class EncryptedNotificationLogStore(context: Context) {
stream.writeInt(payload.cipherText.size) stream.writeInt(payload.cipherText.size)
stream.write(payload.cipherText) stream.write(payload.cipherText)
stream.flush() stream.flush()
file.finishWrite(output) atomicFile.finishWrite(output)
} catch (error: Exception) { } catch (error: Exception) {
file.failWrite(output) atomicFile.failWrite(output)
throw error 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 { private fun readByteArray(stream: DataInputStream, maximumLength: Int): ByteArray {
val length = stream.readInt() val length = stream.readInt()
require(length in 1..maximumLength) { "Invalid encrypted notification-log field length." } require(length in 1..maximumLength) { "Invalid encrypted notification-log field length." }
return ByteArray(length).also(stream::readFully) 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 { 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 FILE_VERSION = 1
const val MAX_IV_BYTES = 32 const val MAX_IV_BYTES = 32
const val MAX_CIPHER_TEXT_BYTES = 50 * 1024 * 1024 const val MAX_CHUNK_CIPHER_TEXT_BYTES = 1024 * 1024
const val MAX_PLAINTEXT_BYTES = 20 * 1024 * 1024 const val MAX_CHUNK_PLAINTEXT_BYTES = 256 * 1024
const val MAX_ENTRIES = 10_000
const val IMAGE_DIRECTORY_NAME = "notification-images"
val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}") val IMAGE_ID_PATTERN = Regex("[0-9a-f-]{36}")
} }
} }
@@ -1,55 +1,77 @@
package se.ajpanton.notificationlog.export package se.ajpanton.notificationlog.export
import android.content.Context
import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogField import se.ajpanton.notificationlog.settings.LogField
import se.ajpanton.notificationlog.settings.LogViewSettings import se.ajpanton.notificationlog.settings.LogViewSettings
import se.ajpanton.notificationlog.settings.TimestampZone import se.ajpanton.notificationlog.settings.TimestampFormatter
import java.text.DateFormat
import java.util.Date
import java.util.TimeZone
object LogExporter { object LogExporter {
fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings): String = fun csv(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String = (
rows(entries, settings).joinToString("\n") { row -> row.joinToString(",") { csvValue(it) } } listOf(csvHeader(settings)) + entries.sortedByDescending { it.recordedAtEpochMillis }.map { csvRow(it, settings, context) }
).joinToString("\n")
fun formatted(entries: List<NotificationLogEntry>, settings: LogViewSettings): String { fun formatted(entries: List<NotificationLogEntry>, settings: LogViewSettings, context: Context? = null): String {
val rows = rows(entries, settings) 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) } } 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( fun html(
entries: List<NotificationLogEntry>, entries: List<NotificationLogEntry>,
settings: LogViewSettings, settings: LogViewSettings,
context: Context? = null,
imagePath: (NotificationLogEntry) -> String? = { null }, imagePath: (NotificationLogEntry) -> String? = { null },
): String = buildString { ): String = buildString {
append("<!doctype html><meta charset=\"utf-8\"><table><thead><tr>") append(htmlStart(settings))
settings.order.filter { it in settings.visibleFields }.forEach { append("<th>${escape(header(it))}</th>") }
append("</tr></thead><tbody>")
entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry -> entries.sortedByDescending { it.recordedAtEpochMillis }.forEach { entry ->
append("<tr>") append(htmlRow(entry, settings, imagePath(entry), context))
settings.order.filter { it in settings.visibleFields }.forEach { field ->
append("<td>${htmlValue(field, entry, settings, imagePath(entry))}</td>")
} }
append("</tr>") append(htmlEnd())
}
append("</tbody></table>")
} }
private fun rows(entries: List<NotificationLogEntry>, settings: LogViewSettings) = listOf( private fun fields(settings: LogViewSettings): List<LogField> = settings.order.filter { it in settings.visibleFields }
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 header(field: LogField) = field.name.lowercase().replace('_', ' ') 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) { 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.APP_NAME -> entry.appName
LogField.PACKAGE_NAME -> entry.packageName LogField.PACKAGE_NAME -> entry.packageName
LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ') LogField.ACTION -> entry.action.name.lowercase().replace('_', ' ')
@@ -62,13 +84,9 @@ object LogExporter {
escaped escaped
} }
} }
private fun timestamp(value: Long, zone: TimestampZone, eventTimeZoneId: String?): String = DateFormat.getDateTimeInstance().apply { private fun timestamp(context: Context?, value: Long, settings: LogViewSettings, eventTimeZoneId: String?): String =
timeZone = when (zone) { TimestampFormatter.format(context, value, settings, eventTimeZoneId)
TimestampZone.UTC -> TimeZone.getTimeZone("UTC") private fun csvLine(values: List<String>): String = values.joinToString(",") { csvValue(it) }
TimestampZone.LOCAL_NOW -> TimeZone.getDefault()
TimestampZone.EVENT_LOCAL -> eventTimeZoneId?.let(TimeZone::getTimeZone) ?: TimeZone.getDefault()
}
}.format(Date(value))
private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\"" private fun csvValue(value: String) = "\"${value.replace("\"", "\"\"")}\""
private fun escape(value: String) = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;") 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_SUMMARY_CANCELLED,
GROUP_OPTIMIZED, GROUP_OPTIMIZED,
UNAUTOBUNDLED, UNAUTOBUNDLED,
BUNDLE_DISMISSED,
CLEAR_DATA, CLEAR_DATA,
ASSISTANT_CANCELLED, ASSISTANT_CANCELLED,
LOCKDOWN, 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 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 { sealed interface AppListItem {
data class App(val value: ListedApp) : AppListItem data class App(val value: ListedApp) : AppListItem
data class SectionTitle(val value: String) : AppListItem
data object Separator : AppListItem data object Separator : AppListItem
} }
object AppListOrdering { 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 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 seen = shown.filter { it.seen }
val other = shown.filterNot { it.seen } val editedUnseen = shown.filter { !it.seen }
val separate = (onlySeen || seenAppsFirst) && seen.isNotEmpty() && other.isNotEmpty() if (!seenAppsFirst || seen.isEmpty()) return shown.map(AppListItem::App)
return buildList { return buildList {
if (separate) { if (!onlySeen) add(AppListItem.SectionTitle("Seen apps"))
if (onlySeen && editedUnseen.isNotEmpty()) add(AppListItem.SectionTitle("Seen apps"))
seen.forEach { add(AppListItem.App(it)) } seen.forEach { add(AppListItem.App(it)) }
if (onlySeen) {
if (editedUnseen.isNotEmpty()) {
add(AppListItem.Separator) add(AppListItem.Separator)
other.forEach { add(AppListItem.App(it)) } add(AppListItem.SectionTitle("Edited apps"))
editedUnseen.forEach { add(AppListItem.App(it)) }
}
} else { } 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)) } shown.forEach { add(AppListItem.App(it)) }
} }
} }
@@ -1,10 +1,11 @@
package se.ajpanton.notificationlog.settings package se.ajpanton.notificationlog.settings
import android.content.Context import android.content.Context
import androidx.core.content.edit
class AppLockStore(context: Context) { class AppLockStore(context: Context) {
private val prefs = context.getSharedPreferences("app-lock", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("app-lock", Context.MODE_PRIVATE)
var enabled: Boolean var enabled: Boolean
get() = prefs.getBoolean("enabled", false) 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 package se.ajpanton.notificationlog.settings
enum class LogField { TIMESTAMP, APP_NAME, PACKAGE_NAME, ACTION, CONTENTS } 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( data class LogViewSettings(
val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS), val visibleFields: Set<LogField> = setOf(LogField.TIMESTAMP, LogField.APP_NAME, LogField.ACTION, LogField.CONTENTS),
val order: List<LogField> = LogField.entries, 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())!! visibleFields = prefs.getStringSet("visible", LogViewSettings().visibleFields.map { it.name }.toSet())!!
.map(LogField::valueOf).toSet(), .map(LogField::valueOf).toSet(),
order = prefs.getString("order", null)?.split(',')?.map(LogField::valueOf) ?: LogField.entries, 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 { fun save(settings: LogViewSettings) = prefs.edit {
putStringSet("visible", settings.visibleFields.map { it.name }.toSet()) putStringSet("visible", settings.visibleFields.map { it.name }.toSet())
putString("order", settings.order.joinToString(",") { it.name }) putString("order", settings.order.joinToString(",") { it.name })
putString("zone", settings.timestampZone.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( fun ruleFor(type: LoggingType): LoggingRule = LoggingRule(
enabled = preferences.getBoolean(key(type, "enabled"), true), 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), ignoreRoutineUpdates = preferences.getBoolean(key(type, "ignore_routine_updates"), true),
) )
@@ -28,34 +20,22 @@ class LoggingRuleStore(context: Context) {
updateListenerComponent() 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) { private fun android.content.SharedPreferences.Editor.write(type: LoggingType, rule: LoggingRule) {
putBoolean(key(type, "enabled"), rule.enabled) 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) putBoolean(key(type, "ignore_routine_updates"), rule.ignoreRoutineUpdates)
} }
private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix" private fun key(type: LoggingType, suffix: String) = "${type.name.lowercase()}.$suffix"
private fun updateListenerComponent() { private fun updateListenerComponent() {
NotificationListenerComponentController.update(appContext, LoggingType.entries.map(::ruleFor)) NotificationListenerComponentController.update(
appContext,
LoggingType.entries.associateWith(::ruleFor),
PerAppEventSettingsStore(appContext).hasEnabledEventOverride(),
)
} }
private companion object { private companion object {
const val FILE_NAME = "logging-rules" const val FILE_NAME = "logging-rules"
} }
} }
internal fun LoggingRule.copyForTarget(): LoggingRule = copy(selectedPackages = selectedPackages.toSet())
@@ -6,6 +6,12 @@ enum class LoggingType {
TEXT_CONTENT, TEXT_CONTENT,
IMAGE_CONTENT, IMAGE_CONTENT,
EDITS, EDITS,
;
companion object {
val eventTypes = listOf(APPEARING, DISAPPEARING, EDITS)
val contentTypes = listOf(TEXT_CONTENT, IMAGE_CONTENT)
}
} }
enum class AppRuleMode { enum class AppRuleMode {
@@ -15,9 +21,5 @@ enum class AppRuleMode {
data class LoggingRule( data class LoggingRule(
val enabled: Boolean = true, 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, 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. * is therefore both the no-work battery mode and the no-start-on-boot mode.
*/ */
internal object NotificationListenerComponentController { 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 applicationContext = context.applicationContext
val component = ComponentName(applicationContext, NotificationCaptureService::class.java) val component = ComponentName(applicationContext, NotificationCaptureService::class.java)
val packageManager = applicationContext.packageManager val packageManager = applicationContext.packageManager
val desired = if (NotificationListenerPolicy.shouldRun(rules)) { val desired = if (NotificationListenerPolicy.shouldRun(rules, hasEnabledEventOverride)) {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED PackageManager.COMPONENT_ENABLED_STATE_ENABLED
} else { } else {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED PackageManager.COMPONENT_ENABLED_STATE_DISABLED
} }
if (packageManager.getComponentEnabledSetting(component) == desired) return if (packageManager.getComponentEnabledSetting(component) != desired) {
packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP) packageManager.setComponentEnabledSetting(component, desired, PackageManager.DONT_KILL_APP)
}
if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) { if (desired == PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
try { 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) NotificationListenerService.requestRebind(component)
} catch (error: SecurityException) { } catch (error: SecurityException) {
Log.w(TAG, "Notification access is not granted yet; Android will bind after the user grants it", error) 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. */ /** The listener is useful only when at least one event type is enabled. */
object NotificationListenerPolicy { 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 package se.ajpanton.notificationlog.settings
object NotificationRuleEvaluator { object NotificationRuleEvaluator {
fun allows(rule: LoggingRule, packageName: String): Boolean { fun allows(filter: AppFilterSettings, packageName: String): Boolean {
if (!rule.enabled) return false val selected = packageName in filter.selectedPackages
val selected = packageName in rule.selectedPackages return when (filter.mode) {
return when (rule.appRuleMode) {
AppRuleMode.WHITELIST -> selected AppRuleMode.WHITELIST -> selected
AppRuleMode.BLACKLIST -> !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"?> <?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" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout" android:id="@+id/drawer_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -24,11 +28,10 @@
app:itemTextColor="@color/drawer_item_text_color" app:itemTextColor="@color/drawer_item_text_color"
app:menu="@menu/drawer_menu" /> app:menu="@menu/drawer_menu" />
<LinearLayout <RelativeLayout
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1">
android:orientation="vertical">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
@@ -37,6 +40,7 @@
android:background="@color/primary" android:background="@color/primary"
android:elevation="0dp" android:elevation="0dp"
android:theme="@style/ThemeOverlay.NotificationLog.Toolbar" android:theme="@style/ThemeOverlay.NotificationLog.Toolbar"
android:layout_alignParentTop="true"
app:navigationIconTint="@color/on_primary" app:navigationIconTint="@color/on_primary"
app:titleTextColor="@color/on_primary" /> app:titleTextColor="@color/on_primary" />
@@ -44,8 +48,9 @@
android:id="@+id/content_frame" android:id="@+id/content_frame"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_below="@id/toolbar"
</LinearLayout> android:layout_alignParentBottom="true" />
</RelativeLayout>
</LinearLayout> </LinearLayout>
<com.google.android.material.navigation.NavigationView <com.google.android.material.navigation.NavigationView
@@ -61,3 +66,22 @@
app:menu="@menu/drawer_menu" /> app:menu="@menu/drawer_menu" />
</se.ajpanton.notificationlog.SafeInsetDrawerLayout> </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:textColor="@color/on_primary"
android:textStyle="bold" /> 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> </LinearLayout>
@@ -1,12 +1,64 @@
<?xml version="1.0" encoding="utf-8"?> <?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 xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="@dimen/page_padding"> android:layout_width="match_parent"
<TextView android:id="@+id/page_title" android:layout_width="match_parent" android:layout_height="wrap_content" android:textAppearance="?attr/textAppearanceHeadline5" /> android:layout_height="match_parent"
<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" /> android:orientation="vertical"
<com.google.android.material.switchmaterial.SwitchMaterial android:id="@+id/app_rule_toggle" android:layout_width="match_parent" android:layout_height="wrap_content" /> android:padding="@dimen/page_padding">
<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
<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" /> android:id="@+id/app_rule_toggle"
<LinearLayout android:id="@+id/app_list" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="24dp" android:orientation="vertical" /> 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> </LinearLayout>
</ScrollView></androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </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"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" <ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:importantForAutofill="noExcludeDescendants">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -9,77 +10,11 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="@dimen/page_padding"> 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 <Button
android:id="@+id/copy_to" android:id="@+id/notification_access"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:enabled="false" android:text="Enable notification access" />
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" />
<Button <Button
android:id="@+id/export_logs" android:id="@+id/export_logs"
@@ -93,17 +28,37 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="Clear logs" /> 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 <com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/app_lock" android:id="@+id/app_lock"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:layout_marginTop="24dp"
android:text="Lock app with phone unlock" /> 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> </LinearLayout>
</ScrollView> </ScrollView>
+27 -50
View File
@@ -1,61 +1,38 @@
<?xml version="1.0" encoding="utf-8"?> <?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">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/logs_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/log_list"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:clipToPadding="false"
android:padding="@dimen/page_padding"> android:paddingStart="@dimen/log_page_horizontal_padding"
android:paddingTop="@dimen/page_padding"
<Button android:paddingEnd="@dimen/log_page_horizontal_padding"
android:id="@+id/notification_access" android:paddingBottom="@dimen/page_padding" />
android:layout_width="wrap_content" </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
android:layout_height="wrap_content"
android:text="Enable notification access" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<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>
<TextView
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." />
<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 <TextView
android:id="@+id/empty_view" android:id="@+id/empty_view"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="24dp" android:layout_margin="24dp"
android:text="No logs yet." /> android:text="No logs yet." />
<LinearLayout <com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/log_rows" android:id="@+id/scroll_to_top"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" /> android:layout_gravity="end|bottom"
</LinearLayout> android:layout_margin="16dp"
</ScrollView> android:contentDescription="Scroll to top"
</LinearLayout> 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:id="@+id/nav_view_logs"
android:title="@string/page_view_logs" /> android:title="@string/page_view_logs" />
</group> </group>
<item
android:id="@+id/nav_settings_header"
android:checkable="false"
android:enabled="false"
android:title="@string/navigation_settings_header" />
<group android:checkableBehavior="single"> <group android:checkableBehavior="single">
<item <item
android:id="@+id/nav_settings" android:id="@+id/nav_settings"
android:title="@string/navigation_indented_settings" /> android:title="@string/navigation_settings_header" />
<item <item
android:id="@+id/nav_appearing" android:id="@+id/nav_log_display"
android:title="@string/navigation_indented_appearing" /> android:title="@string/navigation_indented_log_display" />
<item <item
android:id="@+id/nav_disappearing" android:id="@+id/nav_filter_logging"
android:title="@string/navigation_indented_disappearing" /> android:title="@string/navigation_indented_filter_logging" />
<item <item
android:id="@+id/nav_text_content" android:id="@+id/nav_filter_apps"
android:title="@string/navigation_indented_text_content" /> android:title="@string/navigation_indented_filter_apps" />
<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" />
</group> </group>
</menu> </menu>
+7
View File
@@ -8,4 +8,11 @@
<color name="surface">#121318</color> <color name="surface">#121318</color>
<color name="on_surface">#E3E2E9</color> <color name="on_surface">#E3E2E9</color>
<color name="window_background">#121318</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> </resources>
+7
View File
@@ -8,4 +8,11 @@
<color name="surface">#FBF8FF</color> <color name="surface">#FBF8FF</color>
<color name="on_surface">#1B1B21</color> <color name="on_surface">#1B1B21</color>
<color name="window_background">#FBF8FF</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> </resources>
+2 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<dimen name="navigation_drawer_width">216dp</dimen> <dimen name="navigation_drawer_width">159dp</dimen>
<dimen name="folded_portrait_width">360dp</dimen> <dimen name="folded_portrait_width">360dp</dimen>
<dimen name="navigation_menu_item_height">48dp</dimen> <dimen name="navigation_menu_item_height">48dp</dimen>
<dimen name="drawer_header_height">86dp</dimen> <dimen name="drawer_header_height">86dp</dimen>
@@ -8,4 +8,5 @@
<dimen name="drawer_header_max_height">172dp</dimen> <dimen name="drawer_header_max_height">172dp</dimen>
<dimen name="drawer_header_padding">16dp</dimen> <dimen name="drawer_header_padding">16dp</dimen>
<dimen name="page_padding">24dp</dimen> <dimen name="page_padding">24dp</dimen>
<dimen name="log_page_horizontal_padding">12dp</dimen>
</resources> </resources>
+7 -13
View File
@@ -1,22 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">Notification Log</string> <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="notification_listener_label">Notification Log listener</string>
<string name="navigation_open">Open navigation</string> <string name="navigation_open">Open navigation</string>
<string name="navigation_close">Close 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_view_logs">View logs</string>
<string name="page_settings">Settings</string> <string name="page_settings">Settings</string>
<string name="page_appearing">Appearing</string> <string name="page_filter_apps">Filter apps</string>
<string name="page_disappearing">Disappearing</string> <string name="page_log_display">Log display</string>
<string name="page_text_content">Text content</string> <string name="page_filter_logging">Filter logging</string>
<string name="page_image_content">Image content</string> <string name="navigation_indented_log_display">&#160;&#160;&#160;&#160;Log display</string>
<string name="page_edits">Edits</string> <string name="navigation_indented_filter_logging">&#160;&#160;&#160;&#160;Filter logging</string>
<string name="navigation_indented_settings">&#160;&#160;&#160;&#160;Settings</string> <string name="navigation_indented_filter_apps">&#160;&#160;&#160;&#160;Filter apps</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>
</resources> </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 { class NotificationChangeClassifierTest {
private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot( private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot(
key = "key", packageName = "example.app", textContents = text, hasImage = false, key = "key", packageName = "example.app", textContents = text, hasImage = false,
isRoutine = routine, imageBytes = null, isGroupSummary = false, isRoutine = routine,
) )
@Test fun `text change is an edit`() { @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 package se.ajpanton.notificationlog.export
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import se.ajpanton.notificationlog.model.NotificationAction import se.ajpanton.notificationlog.model.NotificationAction
import se.ajpanton.notificationlog.model.NotificationLogEntry import se.ajpanton.notificationlog.model.NotificationLogEntry
import se.ajpanton.notificationlog.settings.LogViewSettings 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 { class LogExporterTest {
private val imageEntry = NotificationLogEntry( private val imageEntry = NotificationLogEntry(
@@ -30,4 +34,34 @@ class LogExporterTest {
assertFalse(withoutImage.contains("<img")) assertFalse(withoutImage.contains("<img"))
assertTrue(withoutImage.contains("[image]")) 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 import org.junit.Test
class AppListOrderingTest { 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`() { @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)), false, true) val items = AppListOrdering.items(
assertEquals(listOf("Alpha", "Beta", "|", "Zulu"), items.map { if (it is AppListItem.App) it.value.label else "|" }) 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`() { @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")), true, true) val items = AppListOrdering.items(
assertEquals(listOf("Seen", "|", "Chosen"), items.map { if (it is AppListItem.App) it.value.label else "|" }) 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 import org.junit.Test
class NotificationListenerPolicyTest { class NotificationListenerPolicyTest {
@Test fun `listener runs when any logging type is enabled`() { @Test fun `listener runs when any event logging type is enabled`() {
assertTrue(NotificationListenerPolicy.shouldRun(listOf(LoggingRule(enabled = false), LoggingRule(enabled = true)))) assertTrue(NotificationListenerPolicy.shouldRun(rules(LoggingType.EDITS to true)))
} }
@Test fun `listener stays off when every logging type is disabled`() { @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 { class NotificationRuleEvaluatorTest {
@Test fun `default blacklist allows every app`() { @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`() { @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")) assertFalse(NotificationRuleEvaluator.allows(rule, "example.app"))
assertTrue(NotificationRuleEvaluator.allows(rule, "other.app")) assertTrue(NotificationRuleEvaluator.allows(rule, "other.app"))
} }
@Test fun `whitelist accepts only selected 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")) assertTrue(NotificationRuleEvaluator.allows(rule, "example.app"))
assertFalse(NotificationRuleEvaluator.allows(rule, "other.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" fragmentKtx = "1.8.5"
material = "1.10.0" material = "1.10.0"
drawerLayout = "1.2.0" drawerLayout = "1.2.0"
recyclerView = "1.3.2"
swipeRefreshLayout = "1.1.0"
lifecycle = "2.8.7"
junit = "4.13.2" junit = "4.13.2"
[libraries] [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" } fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
drawerlayout = { group = "androidx.drawerlayout", name = "drawerlayout", version.ref = "drawerLayout" } 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" } junit = { group = "junit", name = "junit", version.ref = "junit" }
[plugins] [plugins]
@@ -46,10 +46,12 @@ class MainActivity : AppCompatActivity() {
when (action) { when (action) {
"post_text" -> postText("Text message") "post_text" -> postText("Text message")
"edit_text" -> postText("Edited message") "edit_text" -> postText("Edited message")
"messaging" -> postMessaging() "messaging" -> postMessaging(updated = false)
"messaging_update" -> postMessaging(updated = true)
"image" -> postImage() "image" -> postImage()
"dismissible" -> postDismissible() "dismissible" -> postDismissible()
"big_text" -> postBigText() "big_text" -> postBigText()
"very_long_text" -> postVeryLongText()
"inbox" -> postInbox() "inbox" -> postInbox()
"progress" -> postProgress(25) "progress" -> postProgress(25)
"progress_update" -> postProgress(75) "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 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 me = Person.Builder().setName("Me").build()
val alice = Person.Builder().setName("Alice").build() val alice = Person.Builder().setName("Alice").build()
val style = Notification.MessagingStyle(me) val style = Notification.MessagingStyle(me).addMessage("Hello from Alice", 1, alice)
.addMessage("Hello from Alice", 1, alice) if (updated) {
.addMessage("A reply from me", 2, null as Person?) 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()) 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 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 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()) 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 MESSAGING_ID = 3
const val DISMISSIBLE_ID = 4 const val DISMISSIBLE_ID = 4
const val BIG_TEXT_ID = 5 const val BIG_TEXT_ID = 5
const val VERY_LONG_TEXT_ID = 13
const val INBOX_ID = 6 const val INBOX_ID = 6
const val PROGRESS_ID = 7 const val PROGRESS_ID = 7
const val CHRONOMETER_ID = 8 const val CHRONOMETER_ID = 8
@@ -115,7 +129,9 @@ class MainActivity : AppCompatActivity() {
const val GROUP_KEY = "helper-group" const val GROUP_KEY = "helper-group"
val ACTIONS = listOf( val ACTIONS = listOf(
"Post text" to "post_text", "Edit text" to "edit_text", "Post messaging" to "messaging", "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 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 inbox" to "inbox", "Post progress" to "progress", "Update progress" to "progress_update",
"Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group", "Post chronometer" to "chronometer", "Post timeout" to "timeout", "Post group" to "group",
"Cancel text" to "cancel_text", "Cancel all" to "cancel_all", "Cancel text" to "cancel_text", "Cancel all" to "cancel_all",
+1 -1
View File
@@ -1 +1 @@
version=0.1 version=1.1