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