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