Improve grouped and messaging notification capture
This commit is contained in:
@@ -24,6 +24,7 @@ import se.ajpanton.notificationlog.settings.TimestampZone
|
|||||||
import se.ajpanton.notificationlog.settings.LoggingType
|
import se.ajpanton.notificationlog.settings.LoggingType
|
||||||
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
import se.ajpanton.notificationlog.settings.LoggingRuleStore
|
||||||
import se.ajpanton.notificationlog.settings.AppLockStore
|
import se.ajpanton.notificationlog.settings.AppLockStore
|
||||||
|
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
|
||||||
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.capture.NotificationCaptureService
|
||||||
@@ -84,6 +85,11 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
|||||||
}
|
}
|
||||||
setupCopyControls()
|
setupCopyControls()
|
||||||
setupEventMasterToggles()
|
setupEventMasterToggles()
|
||||||
|
val captureSettings = CaptureSettingsStore(requireContext())
|
||||||
|
binding!!.groupSummaries.isChecked = captureSettings.logGroupSummaries
|
||||||
|
binding!!.groupSummaries.setOnCheckedChangeListener { _, checked ->
|
||||||
|
captureSettings.logGroupSummaries = checked
|
||||||
|
}
|
||||||
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)) }
|
binding!!.notificationAccess.setOnClickListener { startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS)) }
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package se.ajpanton.notificationlog.capture
|
||||||
|
|
||||||
|
/** Applies the global, Android-provided group-summary classification without inspecting text. */
|
||||||
|
object GroupSummaryPolicy {
|
||||||
|
fun shouldLog(snapshot: NotificationSnapshot, logGroupSummaries: Boolean): Boolean =
|
||||||
|
logGroupSummaries || !snapshot.isGroupSummary
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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 java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
@@ -20,12 +21,14 @@ 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 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)
|
||||||
imageStore = EncryptedImageStore(this)
|
imageStore = EncryptedImageStore(this)
|
||||||
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||||
Thread(runnable, "notification-log-writer")
|
Thread(runnable, "notification-log-writer")
|
||||||
@@ -76,6 +79,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
includeContents: Boolean,
|
includeContents: Boolean,
|
||||||
previousSnapshot: NotificationSnapshot? = null,
|
previousSnapshot: NotificationSnapshot? = null,
|
||||||
) {
|
) {
|
||||||
|
if (!GroupSummaryPolicy.shouldLog(snapshot, captureSettings.logGroupSummaries)) return
|
||||||
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
|
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
|
||||||
val appName = appName(snapshot.packageName)
|
val appName = appName(snapshot.packageName)
|
||||||
val retainImage = includeContents && snapshot.imageBytes != null &&
|
val retainImage = includeContents && snapshot.imageBytes != null &&
|
||||||
@@ -117,7 +121,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
|||||||
ruleStore.ruleFor(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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ data class NotificationSnapshot(
|
|||||||
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?,
|
val imageBytes: ByteArray?,
|
||||||
)
|
)
|
||||||
@@ -23,18 +24,25 @@ object NotificationContents {
|
|||||||
fun extract(notification: Notification): String? {
|
fun extract(notification: Notification): String? {
|
||||||
val parts = linkedSetOf<String>()
|
val parts = linkedSetOf<String>()
|
||||||
val extras = notification.extras ?: Bundle.EMPTY
|
val extras = notification.extras ?: Bundle.EMPTY
|
||||||
|
messagingContents(extras)?.let { return it }
|
||||||
extras.getCharSequence(Notification.EXTRA_TITLE)?.addTo(parts)
|
extras.getCharSequence(Notification.EXTRA_TITLE)?.addTo(parts)
|
||||||
extras.getCharSequence(Notification.EXTRA_TEXT)?.addTo(parts)
|
extras.getCharSequence(Notification.EXTRA_TEXT)?.addTo(parts)
|
||||||
extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.addTo(parts)
|
extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.addTo(parts)
|
||||||
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.addTo(parts)
|
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.addTo(parts)
|
||||||
extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)?.addTo(parts)
|
extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)?.addTo(parts)
|
||||||
extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.forEach { it?.addTo(parts) }
|
extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.forEach { it?.addTo(parts) }
|
||||||
|
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MessagingStyle also populates generic title/text fields with an alternate rendering. */
|
||||||
|
private fun messagingContents(extras: Bundle): String? {
|
||||||
|
val parts = linkedSetOf<String>()
|
||||||
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
|
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
|
||||||
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
|
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
|
||||||
).forEach { message ->
|
).forEach { message ->
|
||||||
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
|
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
|
||||||
}
|
}
|
||||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ")
|
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot {
|
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot {
|
||||||
@@ -49,6 +57,7 @@ object NotificationContents {
|
|||||||
hasImage = extras?.let {
|
hasImage = extras?.let {
|
||||||
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
|
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
|
||||||
} == true,
|
} == true,
|
||||||
|
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
|
||||||
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
||||||
extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
|
extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
|
||||||
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() },
|
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() },
|
||||||
|
|||||||
@@ -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, true)
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
android:layout_marginTop="24dp"
|
android:layout_marginTop="24dp"
|
||||||
android:text="Event logging" />
|
android:text="Event logging" />
|
||||||
|
|
||||||
|
<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" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/event_master_toggles"
|
android:id="@+id/event_master_toggles"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package se.ajpanton.notificationlog.capture
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class GroupSummaryPolicyTest {
|
||||||
|
private fun snapshot(isGroupSummary: Boolean) = NotificationSnapshot(
|
||||||
|
key = "key",
|
||||||
|
packageName = "example.app",
|
||||||
|
textContents = null,
|
||||||
|
hasImage = false,
|
||||||
|
isGroupSummary = isGroupSummary,
|
||||||
|
isRoutine = false,
|
||||||
|
imageBytes = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test fun `group summaries are logged by default`() {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+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, imageBytes = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test fun `text change is an edit`() {
|
@Test fun `text change is an edit`() {
|
||||||
|
|||||||
Reference in New Issue
Block a user