11 Commits
18 changed files with 286 additions and 52 deletions
+3 -1
View File
@@ -19,6 +19,8 @@ decisions, physical device testing, and final behaviour are still manually revie
You can choose which event types to record, set app-specific allow/block lists, You can choose which event types to record, set app-specific allow/block lists,
and suppress routine progress or timer updates. The log view can be tailored, and suppress routine progress or timer updates. The log view can be tailored,
expanded for long entries, and exported as CSV, aligned text, or an HTML ZIP. expanded for long entries, and exported as CSV, aligned text, or an HTML ZIP.
Only the HTML ZIP includes saved notification images; CSV and aligned text use
an `[image]` marker instead.
## Privacy ## Privacy
@@ -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
@@ -18,7 +18,6 @@ import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import se.ajpanton.notificationlog.capture.SeenApps import se.ajpanton.notificationlog.capture.SeenApps
@@ -293,13 +292,13 @@ class FilterAppsFragment : Fragment(R.layout.fragment_event_settings) {
} }
} }
holder.filter.contentDescription = "Edit logging events for ${app.label}" holder.filter.contentDescription = "Edit logging events for ${app.label}"
holder.filter.imageTintList = ColorStateList.valueOf( val colors = if (app.hasEventOverride) {
MaterialColors.getColor( R.color.app_filter_override_background to R.color.app_filter_override_icon
holder.filter, } else {
if (app.hasEventOverride) com.google.android.material.R.attr.colorPrimary R.color.app_filter_inherited_background to R.color.app_filter_inherited_icon
else com.google.android.material.R.attr.colorOnSurface, }
), 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) } holder.filter.setOnClickListener { onEventFilterClicked(app) }
} }
if (holder is SectionTitleHolder && item is AppListItem.SectionTitle) { if (holder is SectionTitleHolder && item is AppListItem.SectionTitle) {
@@ -107,7 +107,7 @@ class LogDisplayFragment : Fragment(R.layout.fragment_log_display) {
): LinearLayout = LinearLayout(requireContext()).apply { ): LinearLayout = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setPadding(dp(TIMESTAMP_CONTROLS_INDENT_DP), 0, 0, 0) setPadding(dp(TIMESTAMP_CONTROLS_INDENT_DP), 0, 0, 0)
addDropdown("Timestamp timezone", listOf("Current local timezone", "UTC", "Local timezone when event happened"), TimestampZone.entries.indexOf(currentSettings().timestampZone)) { position -> 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]) val updated = currentSettings().copy(timestampZone = TimestampZone.entries[position])
saveSettings(updated) saveSettings(updated)
} }
@@ -123,7 +123,8 @@ class LogDisplayFragment : Fragment(R.layout.fragment_log_display) {
private fun LinearLayout.addDropdown(label: String, values: List<String>, selected: Int, onSelected: (Int) -> Unit) { private fun LinearLayout.addDropdown(label: String, values: List<String>, selected: Int, onSelected: (Int) -> Unit) {
addView(TextView(context).apply { text = label }) addView(TextView(context).apply { text = label })
addView(Spinner(context).apply { 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) adapter = ArrayAdapter(context, android.R.layout.simple_spinner_dropdown_item, values)
setSelection(selected) setSelection(selected)
onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener { onItemSelectedListener = object : android.widget.AdapterView.OnItemSelectedListener {
@@ -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
@@ -23,23 +24,27 @@ 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.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(this).enabled) 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
@@ -85,7 +90,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (needsUnlock && AppLockStore(this).enabled) { if (needsUnlock && appLockStore.enabled) {
showLockedOverlay(true) showLockedOverlay(true)
requestUnlock() requestUnlock()
} else { } else {
@@ -97,7 +102,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
super.onStop() super.onStop()
if (!isChangingConfigurations) { if (!isChangingConfigurations) {
needsUnlock = true needsUnlock = true
if (AppLockStore(this).enabled) showLockedOverlay(true) if (appLockStore.enabled) showLockedOverlay(true)
} }
} }
@@ -119,6 +124,18 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
binding.lockOverlay.visibility = if (visible) View.VISIBLE else View.GONE 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 {
navigateTo(item.itemId) navigateTo(item.itemId)
if (!permanentSidebar) { if (!permanentSidebar) {
@@ -146,6 +163,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte
runOnCommit(::applyPageTitleVisibility) runOnCommit(::applyPageTitleVisibility)
} }
title = getString(page.titleRes) title = getString(page.titleRes)
invalidateOptionsMenu()
} }
private fun setupNavigationView(navigationView: NavigationView) { private fun setupNavigationView(navigationView: NavigationView) {
@@ -289,6 +307,38 @@ 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,
@@ -307,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
} }
} }
@@ -19,6 +19,7 @@ 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.StorageLimits
import se.ajpanton.notificationlog.settings.StorageLimitsStore 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.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -38,7 +39,9 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) } 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() bindStorageLimits()
} }
override fun onDestroyView() { binding = null; super.onDestroyView() } override fun onDestroyView() { binding = null; super.onDestroyView() }
@@ -75,21 +78,34 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
} }
private fun confirmClearLogs() { private fun confirmClearLogs() {
MaterialAlertDialogBuilder(requireContext()) val filterStore = LogAppFilterStore(requireContext())
val dialog = MaterialAlertDialogBuilder(requireContext())
.setTitle("Clear logs?") .setTitle("Clear logs?")
.setMessage("This permanently removes every stored notification log and copied image.") .setMessage("This permanently removes every stored notification log and copied image.")
.setNegativeButton("Cancel", null) .setNegativeButton("Cancel", null)
.setPositiveButton("Clear") { _, _ -> .setPositiveButton("Clear") { _, _ ->
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { clearLogs()
EncryptedNotificationLogStore(requireContext().applicationContext).clear()
}
} }
.show() if (filterStore.selectedPackages().isNotEmpty()) {
dialog.setNeutralButton("Clear and reset filter") { _, _ ->
filterStore.clear()
(activity as? MainActivity)?.updateLogFilterAction()
clearLogs()
}
}
dialog.show()
}
private fun clearLogs() {
val appContext = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
EncryptedNotificationLogStore(appContext).clear()
}
} }
private fun chooseExportFormat() { 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}")
} }
@@ -12,6 +12,7 @@ import android.content.ComponentName
import android.app.NotificationManager import android.app.NotificationManager
import android.text.TextPaint 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
@@ -26,6 +27,7 @@ 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.LogAppFilterStore
import se.ajpanton.notificationlog.settings.TimestampFormatter import se.ajpanton.notificationlog.settings.TimestampFormatter
import se.ajpanton.notificationlog.settings.DisplayEvent import se.ajpanton.notificationlog.settings.DisplayEvent
import se.ajpanton.notificationlog.capture.NotificationCaptureService import se.ajpanton.notificationlog.capture.NotificationCaptureService
@@ -49,10 +51,12 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
private var noMoreRows = false private var noMoreRows = false
private var loadGeneration = 0 private var loadGeneration = 0
private val logAdapter = LogAdapter() 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)
appFilterStore = LogAppFilterStore(requireContext())
binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) } binding!!.logsRefresh.setOnRefreshListener { loadLogs(reset = true) }
binding!!.logList.layoutManager = LinearLayoutManager(requireContext()) binding!!.logList.layoutManager = LinearLayoutManager(requireContext())
binding!!.logList.adapter = logAdapter binding!!.logList.adapter = logAdapter
@@ -82,7 +86,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
private fun loadLogs(reset: Boolean) { private fun loadLogs(reset: Boolean) {
if (loading) return if (loading && !reset) return
if (!reset && noMoreRows) return if (!reset && noMoreRows) return
val context = requireContext().applicationContext val context = requireContext().applicationContext
if (reset) { if (reset) {
@@ -98,30 +102,93 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
val page = withContext(Dispatchers.IO) { val page = withContext(Dispatchers.IO) {
EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE) EncryptedNotificationLogStore(context).readNewest(nextCursor, PAGE_SIZE)
} }
if (generation == loadGeneration) { if (generation != loadGeneration) return@launch
nextCursor = page.nextCursor nextCursor = page.nextCursor
noMoreRows = nextCursor == null noMoreRows = nextCursor == null
val addedRows = page.entries.map(::LogRow) val visibleBefore = visibleRows(LogViewSettingsStore(requireContext()).load()).size
rows += addedRows rows += page.entries.map(::LogRow)
binding?.let(::render) binding?.let(::render)
} val visibleAfter = visibleRows(LogViewSettingsStore(requireContext()).load()).size
loading = false val skipFilteredPage = appFilterStore.selectedPackages().isNotEmpty() &&
binding?.logsRefresh?.isRefreshing = false visibleAfter == visibleBefore && !noMoreRows
loading = false
binding?.logsRefresh?.isRefreshing = false
if (skipFilteredPage) loadLogs(reset = false)
} }
} }
private fun render(view: FragmentViewLogsBinding) { private fun render(view: FragmentViewLogsBinding) {
view.emptyView.visibility = if (rows.isEmpty()) View.VISIBLE else View.GONE val settings = LogViewSettingsStore(requireContext()).load()
val visibleRows = visibleRows(settings)
view.emptyView.visibility = if (visibleRows.isEmpty()) View.VISIBLE else View.GONE
view.emptyView.text = if (hasNotificationAccess()) { view.emptyView.text = if (hasNotificationAccess()) {
"No logs yet." if (appFilterStore.selectedPackages().isEmpty()) "No logs yet." else "No logs match the app filter."
} else { } else {
"Notification access is disabled. Enable it in Settings." "Notification access is disabled. Enable it in Settings."
} }
val settings = LogViewSettingsStore(requireContext()).load()
val visibleRows = rows.filter { eventFor(it.entry.action) in settings.visibleEvents }
logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings)) logAdapter.submit(visibleRows, settings, metadataColumnWidth(view.logList.width, visibleRows, settings))
} }
/** Called by MainActivity's header action. Selections persist until Reset is chosen. */
fun showAppFilter() {
val appContext = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val apps = withContext(Dispatchers.IO) {
val byPackage = linkedMapOf<String, LoggedApp>()
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 { private fun logRowView(row: LogRow, settings: LogViewSettings, metadataColumnWidth: Int): View = LinearLayout(requireContext()).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = android.view.Gravity.TOP gravity = android.view.Gravity.TOP
@@ -224,10 +291,10 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
} }
addView(message) addView(message)
if (expanded && row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) { if (expanded && row.entry.action == NotificationAction.EDITED && !row.entry.previousContents.isNullOrEmpty()) {
addView(previousDivider())
addView(TextView(context).apply { addView(TextView(context).apply {
text = "Previous: ${row.entry.previousContents}" text = row.entry.previousContents
setLineSpacing(0f, 1f) setLineSpacing(0f, 1f)
setPadding(0, dp(4), 0, 0)
}) })
} }
if (expanded && row.entry.imageId != null) { if (expanded && row.entry.imageId != null) {
@@ -260,6 +327,24 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
setOnLongClickListener { showDeleteDialog(row.entry.id) } 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 { 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.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.APP_NAME in settings.visibleFields) add(MetadataValue(entry.appName, 15f, indented = true, bold = true))
@@ -463,6 +548,7 @@ class ViewLogsFragment : Fragment(R.layout.fragment_view_logs) {
) )
private data class LogRow(val entry: NotificationLogEntry) private data class LogRow(val entry: NotificationLogEntry)
private data class LoggedApp(val name: String, val packageName: String)
private data class MetadataValue( private data class MetadataValue(
val text: String, val text: String,
val textSize: Float, val textSize: Float,
@@ -56,10 +56,12 @@ object NotificationContents {
/** MessagingStyle also populates generic title/text fields with an alternate rendering. */ /** MessagingStyle also populates generic title/text fields with an alternate rendering. */
private fun messagingContents(extras: Bundle): String? { private fun messagingContents(extras: Bundle): String? {
val parts = linkedSetOf<String>() val parts = linkedSetOf<String>()
Notification.MessagingStyle.Message.getMessagesFromBundleArray( listOf(Notification.EXTRA_HISTORIC_MESSAGES, Notification.EXTRA_MESSAGES).forEach { key ->
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java), Notification.MessagingStyle.Message.getMessagesFromBundleArray(
).forEach { message -> extras.getParcelableArray(key, Bundle::class.java),
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts) ).forEach { message ->
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
}
} }
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n") return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
} }
@@ -76,7 +78,11 @@ object NotificationContents {
} == true, } == true,
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0, isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true || isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
extras?.containsKey(Notification.EXTRA_PROGRESS) == true, extras?.getBoolean(Notification.EXTRA_PROGRESS_INDETERMINATE, false) == true ||
extras?.let {
it.getInt(Notification.EXTRA_PROGRESS_MAX, 0) > 0 &&
it.getInt(Notification.EXTRA_PROGRESS, -1) >= 0
} == true,
) )
} }
@@ -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,7 +1,7 @@
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 TimestampDateFormat { SYSTEM_DEFAULT, YEAR_MONTH_DAY, DAY_MONTH_YEAR, MONTH_DAY_YEAR }
enum class TimestampClockFormat { SYSTEM_DEFAULT, HOUR_24, HOUR_12 } enum class TimestampClockFormat { SYSTEM_DEFAULT, HOUR_24, HOUR_12 }
enum class DisplayEvent { APPEARING, DISAPPEARING, EDITS } enum class DisplayEvent { APPEARING, DISAPPEARING, EDITS }
@@ -9,7 +9,7 @@ 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 timestampDateFormat: TimestampDateFormat = TimestampDateFormat.SYSTEM_DEFAULT,
val timestampClockFormat: TimestampClockFormat = TimestampClockFormat.SYSTEM_DEFAULT, val timestampClockFormat: TimestampClockFormat = TimestampClockFormat.SYSTEM_DEFAULT,
val visibleEvents: Set<DisplayEvent> = DisplayEvent.entries.toSet(), val visibleEvents: Set<DisplayEvent> = DisplayEvent.entries.toSet(),
@@ -9,7 +9,7 @@ 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)!!), timestampDateFormat = TimestampDateFormat.valueOf(prefs.getString("date_format", TimestampDateFormat.SYSTEM_DEFAULT.name)!!),
timestampClockFormat = TimestampClockFormat.valueOf(prefs.getString("clock_format", TimestampClockFormat.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())!! visibleEvents = prefs.getStringSet("visible_events", DisplayEvent.entries.map { it.name }.toSet())!!
@@ -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,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>
@@ -39,7 +39,7 @@
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="Storage limits" /> android:text="Storage limits (MiB)" />
<EditText <EditText
android:id="@+id/log_limit_mib" android:id="@+id/log_limit_mib"
+6
View File
@@ -9,4 +9,10 @@
<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="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>
+6
View File
@@ -9,4 +9,10 @@
<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="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>
@@ -46,7 +46,8 @@ 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()
@@ -66,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())
} }
@@ -126,6 +129,7 @@ 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 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",
+1 -1
View File
@@ -1 +1 @@
version=0.2 version=1.1