Add diagnostic mode for notification capture
When enabled, the native listener captures every post from monitored apps (including empty/duplicate placeholders like "Конфиденциально") and stores a full dump of key/postTime/visibility/flags/extras in the new raw_messages.diagnostics column (schemaVersion 9), shown in the parsing-log detail panel. Dedup is salted with postTime in this mode so two distinct posts (placeholder then real-text update) both appear while reboot redelivery still collapses. Toggle lives in parsing settings (Android), synced to native via the ingest worker. Also gate the parsing worker drain on the feature being enabled, with an explicit re-drain when it is turned back on. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-1
@@ -30,7 +30,7 @@ object NotificationIngestPlugin {
|
||||
|
||||
fun register(engine: FlutterEngine, context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val store = NotificationQueueStore(appContext)
|
||||
val store = NotificationQueueStore.getInstance(appContext)
|
||||
|
||||
MethodChannel(engine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
@@ -61,6 +61,11 @@ object NotificationIngestPlugin {
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"setDiagnosticMode" -> {
|
||||
store.setDiagnosticMode(call.arguments as? Boolean ?: false)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
"getInstalledApps" -> {
|
||||
// Тяжёлый I/O (загрузка иконок) — уводим с main-потока,
|
||||
// результат резолвим обратно на main (требование канала).
|
||||
@@ -87,6 +92,9 @@ object NotificationIngestPlugin {
|
||||
obj.optString(NotificationQueueStore.KEY_BODY),
|
||||
NotificationQueueStore.KEY_RECEIVED_AT to
|
||||
obj.optLong(NotificationQueueStore.KEY_RECEIVED_AT),
|
||||
NotificationQueueStore.KEY_DIAGNOSTICS to
|
||||
if (obj.isNull(NotificationQueueStore.KEY_DIAGNOSTICS)) null
|
||||
else obj.optString(NotificationQueueStore.KEY_DIAGNOSTICS),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+48
-3
@@ -15,7 +15,7 @@ import android.service.notification.StatusBarNotification
|
||||
*/
|
||||
class NotificationIngestService : NotificationListenerService() {
|
||||
|
||||
private val store by lazy { NotificationQueueStore(applicationContext) }
|
||||
private val store by lazy { NotificationQueueStore.getInstance(applicationContext) }
|
||||
|
||||
override fun onNotificationPosted(sbn: StatusBarNotification?) {
|
||||
val notification = sbn?.notification ?: return
|
||||
@@ -33,9 +33,54 @@ class NotificationIngestService : NotificationListenerService() {
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
|
||||
if (body.isEmpty()) return
|
||||
val diagnosticMode = store.isDiagnosticMode()
|
||||
|
||||
store.enqueue(packageName, title, body, sbn.postTime)
|
||||
if (body.isEmpty() && !diagnosticMode) return
|
||||
|
||||
if (diagnosticMode) {
|
||||
// Режим диагностики: ловим КАЖДЫЙ пост (включая пустые/заглушки),
|
||||
// снимаем полный дамп для отладки проблемы «Конфиденциально».
|
||||
// Пустой body заменяем маркером, чтобы строка журнала и пайплайн
|
||||
// не падали (реальный текст ищем в секции «Диагностика»).
|
||||
val effectiveBody = body.ifEmpty { title ?: "[нет текста — см. диагностику]" }
|
||||
val dump = buildDiagnostics(sbn, notification, extras)
|
||||
store.enqueue(packageName, title, effectiveBody, sbn.postTime, dump)
|
||||
} else {
|
||||
store.enqueue(packageName, title, body, sbn.postTime)
|
||||
}
|
||||
LiveSink.tick()
|
||||
}
|
||||
|
||||
/**
|
||||
* Человекочитаемый дамп уведомления для режима диагностики: `key`,
|
||||
* `postTime`, `visibility`, `flags`, `tickerText` и все поля `extras`.
|
||||
* Длина ограничена [MAX_DIAGNOSTICS_LEN], чтобы не раздувать БД.
|
||||
*/
|
||||
private fun buildDiagnostics(
|
||||
sbn: StatusBarNotification,
|
||||
notification: Notification,
|
||||
extras: android.os.Bundle,
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("key=").append(sbn.key).append('\n')
|
||||
sb.append("postTime=").append(sbn.postTime).append('\n')
|
||||
sb.append("visibility=").append(notification.visibility).append('\n')
|
||||
sb.append("flags=").append(notification.flags).append('\n')
|
||||
notification.tickerText?.let { sb.append("tickerText=").append(it).append('\n') }
|
||||
sb.append("-- extras --\n")
|
||||
for (k in extras.keySet()) {
|
||||
val v = extras.get(k)
|
||||
val rendered = when (v) {
|
||||
is Array<*> -> v.joinToString(" | ") { it?.toString().orEmpty() }
|
||||
else -> v?.toString().orEmpty()
|
||||
}
|
||||
sb.append(k).append(" = ").append(rendered).append('\n')
|
||||
if (sb.length > MAX_DIAGNOSTICS_LEN) break
|
||||
}
|
||||
return sb.toString().take(MAX_DIAGNOSTICS_LEN)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_DIAGNOSTICS_LEN = 4000
|
||||
}
|
||||
}
|
||||
|
||||
+34
-3
@@ -14,20 +14,27 @@ import org.json.JSONObject
|
||||
* мониторимых пакетов (синхронизируется из Dart), чтобы префильтровать на
|
||||
* нативной стороне и не плодить мусор.
|
||||
*/
|
||||
class NotificationQueueStore(context: Context) {
|
||||
class NotificationQueueStore private constructor(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
/** Добавляет уведомление в очередь (с ограничением размера). */
|
||||
@Synchronized
|
||||
fun enqueue(packageName: String, title: String?, body: String, postTimeMillis: Long) {
|
||||
fun enqueue(
|
||||
packageName: String,
|
||||
title: String?,
|
||||
body: String,
|
||||
postTimeMillis: Long,
|
||||
diagnostics: String? = null,
|
||||
) {
|
||||
val array = readQueue()
|
||||
val item = JSONObject().apply {
|
||||
put(KEY_PACKAGE, packageName)
|
||||
put(KEY_TITLE, title ?: JSONObject.NULL)
|
||||
put(KEY_BODY, body)
|
||||
put(KEY_RECEIVED_AT, postTimeMillis)
|
||||
put(KEY_DIAGNOSTICS, diagnostics ?: JSONObject.NULL)
|
||||
}
|
||||
array.put(item)
|
||||
// Держим только последние MAX_QUEUE элементов, чтобы не разрастаться.
|
||||
@@ -60,6 +67,14 @@ class NotificationQueueStore(context: Context) {
|
||||
fun getMonitoredPackages(): Set<String> =
|
||||
prefs.getStringSet(KEY_MONITORED, emptySet()) ?: emptySet()
|
||||
|
||||
@Synchronized
|
||||
fun setDiagnosticMode(enabled: Boolean) {
|
||||
prefs.edit().putBoolean(KEY_DIAGNOSTIC, enabled).apply()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun isDiagnosticMode(): Boolean = prefs.getBoolean(KEY_DIAGNOSTIC, false)
|
||||
|
||||
private fun readQueue(): JSONArray {
|
||||
val raw = prefs.getString(KEY_QUEUE, null) ?: return JSONArray()
|
||||
return try {
|
||||
@@ -70,14 +85,30 @@ class NotificationQueueStore(context: Context) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Процесс-синглтон: сервис (binder-поток) и плагин (main-поток) обязаны
|
||||
* делить ОДИН инстанс, иначе `@Synchronized` лочит разные мониторы и
|
||||
* enqueue/drainAll над общим SharedPreferences-ключом не исключают друг
|
||||
* друга (гонка — потеря/воскрешение элементов очереди).
|
||||
*/
|
||||
@Volatile
|
||||
private var instance: NotificationQueueStore? = null
|
||||
|
||||
fun getInstance(context: Context): NotificationQueueStore =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: NotificationQueueStore(context).also { instance = it }
|
||||
}
|
||||
|
||||
private const val PREFS_NAME = "notif_ingest"
|
||||
private const val KEY_QUEUE = "queue"
|
||||
private const val KEY_MONITORED = "monitored_packages"
|
||||
private const val KEY_DIAGNOSTIC = "diagnostic_mode"
|
||||
private const val MAX_QUEUE = 500
|
||||
|
||||
const val KEY_PACKAGE = "packageName"
|
||||
const val KEY_TITLE = "title"
|
||||
const val KEY_BODY = "body"
|
||||
const val KEY_RECEIVED_AT = "receivedAt"
|
||||
const val KEY_DIAGNOSTICS = "diagnostics"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user