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:
2026-06-29 00:17:50 +03:00
co-authored by Claude Opus 4.8
parent 6efc8cef0a
commit 1e15250c27
21 changed files with 295 additions and 14 deletions
@@ -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),
)
)
}
@@ -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
}
}
@@ -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"
}
}