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.LoggingRuleStore
|
||||
import se.ajpanton.notificationlog.settings.AppLockStore
|
||||
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
|
||||
import se.ajpanton.notificationlog.data.EncryptedNotificationLogStore
|
||||
import se.ajpanton.notificationlog.data.EncryptedImageStore
|
||||
import se.ajpanton.notificationlog.capture.NotificationCaptureService
|
||||
@@ -84,6 +85,11 @@ class SettingsFragment : Fragment(R.layout.fragment_settings) {
|
||||
}
|
||||
setupCopyControls()
|
||||
setupEventMasterToggles()
|
||||
val captureSettings = CaptureSettingsStore(requireContext())
|
||||
binding!!.groupSummaries.isChecked = captureSettings.logGroupSummaries
|
||||
binding!!.groupSummaries.setOnCheckedChangeListener { _, checked ->
|
||||
captureSettings.logGroupSummaries = checked
|
||||
}
|
||||
binding!!.exportLogs.setOnClickListener { confirmExport() }
|
||||
binding!!.clearLogs.setOnClickListener { confirmClearLogs() }
|
||||
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.LoggingType
|
||||
import se.ajpanton.notificationlog.settings.NotificationRuleEvaluator
|
||||
import se.ajpanton.notificationlog.settings.CaptureSettingsStore
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@@ -20,12 +21,14 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
private lateinit var logStore: EncryptedNotificationLogStore
|
||||
private lateinit var writeExecutor: ExecutorService
|
||||
private lateinit var ruleStore: LoggingRuleStore
|
||||
private lateinit var captureSettings: CaptureSettingsStore
|
||||
private lateinit var imageStore: EncryptedImageStore
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
logStore = EncryptedNotificationLogStore(this)
|
||||
ruleStore = LoggingRuleStore(this)
|
||||
captureSettings = CaptureSettingsStore(this)
|
||||
imageStore = EncryptedImageStore(this)
|
||||
writeExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "notification-log-writer")
|
||||
@@ -76,6 +79,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
includeContents: Boolean,
|
||||
previousSnapshot: NotificationSnapshot? = null,
|
||||
) {
|
||||
if (!GroupSummaryPolicy.shouldLog(snapshot, captureSettings.logGroupSummaries)) return
|
||||
if (!NotificationRuleEvaluator.allows(ruleStore.ruleFor(loggingType), snapshot.packageName)) return
|
||||
val appName = appName(snapshot.packageName)
|
||||
val retainImage = includeContents && snapshot.imageBytes != null &&
|
||||
@@ -117,7 +121,7 @@ class NotificationCaptureService : NotificationListenerService() {
|
||||
ruleStore.ruleFor(LoggingType.IMAGE_CONTENT), snapshot.packageName,
|
||||
)
|
||||
return listOfNotNull(text, if (image) "[image]" else null)
|
||||
.joinToString(" — ")
|
||||
.joinToString("\n")
|
||||
.take(MAX_CONTENT_CHARACTERS)
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ data class NotificationSnapshot(
|
||||
val packageName: String,
|
||||
val textContents: String?,
|
||||
val hasImage: Boolean,
|
||||
val isGroupSummary: Boolean,
|
||||
val isRoutine: Boolean,
|
||||
val imageBytes: ByteArray?,
|
||||
)
|
||||
@@ -23,18 +24,25 @@ object NotificationContents {
|
||||
fun extract(notification: Notification): String? {
|
||||
val parts = linkedSetOf<String>()
|
||||
val extras = notification.extras ?: Bundle.EMPTY
|
||||
messagingContents(extras)?.let { return it }
|
||||
extras.getCharSequence(Notification.EXTRA_TITLE)?.addTo(parts)
|
||||
extras.getCharSequence(Notification.EXTRA_TEXT)?.addTo(parts)
|
||||
extras.getCharSequence(Notification.EXTRA_BIG_TEXT)?.addTo(parts)
|
||||
extras.getCharSequence(Notification.EXTRA_SUB_TEXT)?.addTo(parts)
|
||||
extras.getCharSequence(Notification.EXTRA_SUMMARY_TEXT)?.addTo(parts)
|
||||
extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES)?.forEach { it?.addTo(parts) }
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
||||
}
|
||||
|
||||
/** MessagingStyle also populates generic title/text fields with an alternate rendering. */
|
||||
private fun messagingContents(extras: Bundle): String? {
|
||||
val parts = linkedSetOf<String>()
|
||||
Notification.MessagingStyle.Message.getMessagesFromBundleArray(
|
||||
extras.getParcelableArray(Notification.EXTRA_MESSAGES, Bundle::class.java),
|
||||
).forEach { message ->
|
||||
listOfNotNull(message.senderPerson?.name, message.text).joinToString(": ").addTo(parts)
|
||||
}
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" — ")
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString("\n")
|
||||
}
|
||||
|
||||
fun snapshot(sbn: StatusBarNotification, context: Context): NotificationSnapshot {
|
||||
@@ -49,6 +57,7 @@ object NotificationContents {
|
||||
hasImage = extras?.let {
|
||||
it.containsKey(Notification.EXTRA_PICTURE) || it.containsKey(Notification.EXTRA_PICTURE_ICON)
|
||||
} == true,
|
||||
isGroupSummary = notification.flags and Notification.FLAG_GROUP_SUMMARY != 0,
|
||||
isRoutine = extras?.getBoolean(Notification.EXTRA_SHOW_CHRONOMETER, false) == true ||
|
||||
extras?.containsKey(Notification.EXTRA_PROGRESS) == true,
|
||||
imageBytes = picture?.toPng() ?: pictureIcon?.let { icon -> icon.loadDrawable(context)?.toBitmap()?.toPng() },
|
||||
|
||||
@@ -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: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
|
||||
android:id="@+id/event_master_toggles"
|
||||
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 {
|
||||
private fun snapshot(text: String, routine: Boolean = false) = NotificationSnapshot(
|
||||
key = "key", packageName = "example.app", textContents = text, hasImage = false,
|
||||
isRoutine = routine, imageBytes = null,
|
||||
isGroupSummary = false, isRoutine = routine, imageBytes = null,
|
||||
)
|
||||
|
||||
@Test fun `text change is an edit`() {
|
||||
|
||||
Reference in New Issue
Block a user