diff --git a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt index 12f4aff..3c2f0bf 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/SettingsFragment.kt @@ -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)) } diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/GroupSummaryPolicy.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/GroupSummaryPolicy.kt new file mode 100644 index 0000000..4ea3340 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/GroupSummaryPolicy.kt @@ -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 +} diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt index 216f21e..9c95a4a 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationCaptureService.kt @@ -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 } } diff --git a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt index 8536639..5d01b8a 100644 --- a/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt +++ b/app/src/main/java/se/ajpanton/notificationlog/capture/NotificationSnapshot.kt @@ -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() 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() 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() }, diff --git a/app/src/main/java/se/ajpanton/notificationlog/settings/CaptureSettingsStore.kt b/app/src/main/java/se/ajpanton/notificationlog/settings/CaptureSettingsStore.kt new file mode 100644 index 0000000..6092d75 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationlog/settings/CaptureSettingsStore.kt @@ -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" + } +} diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 5471069..c137dd8 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -21,6 +21,12 @@ android:layout_marginTop="24dp" android:text="Event logging" /> + +