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"
}
}
+3
View File
@@ -208,6 +208,8 @@
"parsingEnableLabel": "Recognize notifications",
"parsingAutoApplyLabel": "Auto-add transactions",
"parsingAutoApplyHint": "Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.",
"parsingDiagnosticModeLabel": "Diagnostic mode",
"parsingDiagnosticModeHint": "Capture every notification from monitored apps (incl. empty/duplicate) with a full dump of its fields. Temporary — for debugging, adds noise to the parsing log.",
"inboxWhyNotAuto": "Why not automatic: {reasons}",
"@inboxWhyNotAuto": { "placeholders": { "reasons": { "type": "String" } } },
"gateCheckRuleMatched": "no rule for this merchant",
@@ -256,6 +258,7 @@
"parsingDetailDraft": "Draft",
"parsingDetailRule": "Rule suggestion",
"parsingDetailMeta": "Meta",
"parsingDetailDiagnostics": "Diagnostics",
"parsingDetailAttempt": "Attempt {count}/{max}",
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
"parsingDetailSource": "Source",
+18
View File
@@ -920,6 +920,18 @@ abstract class AppLocalizations {
/// **'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.'**
String get parsingAutoApplyHint;
/// No description provided for @parsingDiagnosticModeLabel.
///
/// In ru, this message translates to:
/// **'Режим диагностики'**
String get parsingDiagnosticModeLabel;
/// No description provided for @parsingDiagnosticModeHint.
///
/// In ru, this message translates to:
/// **'Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.'**
String get parsingDiagnosticModeHint;
/// No description provided for @inboxWhyNotAuto.
///
/// In ru, this message translates to:
@@ -1178,6 +1190,12 @@ abstract class AppLocalizations {
/// **'Мета'**
String get parsingDetailMeta;
/// No description provided for @parsingDetailDiagnostics.
///
/// In ru, this message translates to:
/// **'Диагностика'**
String get parsingDetailDiagnostics;
/// No description provided for @parsingDetailAttempt.
///
/// In ru, this message translates to:
+10
View File
@@ -475,6 +475,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get parsingAutoApplyHint =>
'Add without confirmation when a rule exists, the amount is found in the message text and the account is trusted.';
@override
String get parsingDiagnosticModeLabel => 'Diagnostic mode';
@override
String get parsingDiagnosticModeHint =>
'Capture every notification from monitored apps (incl. empty/duplicate) with a full dump of its fields. Temporary — for debugging, adds noise to the parsing log.';
@override
String inboxWhyNotAuto(String reasons) {
return 'Why not automatic: $reasons';
@@ -613,6 +620,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get parsingDetailMeta => 'Meta';
@override
String get parsingDetailDiagnostics => 'Diagnostics';
@override
String parsingDetailAttempt(int count, int max) {
return 'Attempt $count/$max';
+10
View File
@@ -487,6 +487,13 @@ class AppLocalizationsRu extends AppLocalizations {
String get parsingAutoApplyHint =>
'Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.';
@override
String get parsingDiagnosticModeLabel => 'Режим диагностики';
@override
String get parsingDiagnosticModeHint =>
'Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.';
@override
String inboxWhyNotAuto(String reasons) {
return 'Почему не автоматически: $reasons';
@@ -624,6 +631,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get parsingDetailMeta => 'Мета';
@override
String get parsingDetailDiagnostics => 'Диагностика';
@override
String parsingDetailAttempt(int count, int max) {
return 'Попытка $count/$max';
+3
View File
@@ -208,6 +208,8 @@
"parsingEnableLabel": "Распознавать уведомления",
"parsingAutoApplyLabel": "Авто-добавление транзакций",
"parsingAutoApplyHint": "Добавлять без подтверждения, когда есть правило, сумма найдена в тексте сообщения и счёт определён надёжно.",
"parsingDiagnosticModeLabel": "Режим диагностики",
"parsingDiagnosticModeHint": "Ловить каждое уведомление от мониторимых приложений (включая пустые/повторные) с полным дампом всех полей. Временно — для отладки, добавляет шум в журнал парсинга.",
"inboxWhyNotAuto": "Почему не автоматически: {reasons}",
"@inboxWhyNotAuto": { "placeholders": { "reasons": { "type": "String" } } },
"gateCheckRuleMatched": "нет правила для мерчанта",
@@ -256,6 +258,7 @@
"parsingDetailDraft": "Черновик",
"parsingDetailRule": "Предложение правила",
"parsingDetailMeta": "Мета",
"parsingDetailDiagnostics": "Диагностика",
"parsingDetailAttempt": "Попытка {count}/{max}",
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
"parsingDetailSource": "Источник",
+5 -1
View File
@@ -68,7 +68,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase.forTesting(super.executor);
@override
int get schemaVersion => 8;
int get schemaVersion => 9;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -131,6 +131,10 @@ class AppDatabase extends _$AppDatabase {
// typeMatchesRule).
await m.addColumn(parseRulesTable, parseRulesTable.txType);
}
if (from < 9) {
// v8 → v9: дамп нативного уведомления для режима диагностики.
await m.addColumn(rawMessagesTable, rawMessagesTable.diagnostics);
}
},
);
@@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'notification_parsing_providers.dart';
import 'parsing_settings_controller.dart';
part 'notification_ingest_worker.g.dart';
@@ -40,6 +41,21 @@ class NotificationIngestWorker extends _$NotificationIngestWorker {
);
ref.onDispose(pkgSub.close);
// Синхронизируем флаг режима диагностики в native (старт + изменение).
final diagSub = ref.listen(
parsingSettingsControllerProvider,
(_, next) {
final enabled = next.value?.diagnosticModeEnabled;
if (enabled != null) {
ref
.read(notificationListenerChannelProvider)
.setDiagnosticMode(enabled);
}
},
fireImmediately: true,
);
ref.onDispose(diagSub.close);
// Тик из EventChannel «появилось новое уведомление» → дрейн.
final tickSub = ref
.read(notificationListenerChannelProvider)
@@ -78,6 +94,7 @@ class NotificationIngestWorker extends _$NotificationIngestWorker {
title: item.title,
body: item.body,
receivedAt: item.receivedAt,
diagnostics: item.diagnostics,
);
}
} while (_rerun);
@@ -15,6 +15,7 @@ class ParsingSettings {
required this.aiModel,
required this.aiDailyTokenLimit,
required this.tokensUsedToday,
required this.diagnosticModeEnabled,
});
/// Распознавать уведомления (мастер-тумблер всей фичи).
@@ -37,6 +38,12 @@ class ParsingSettings {
/// Израсходовано токенов сегодня (для UI и daily-limit).
final int tokensUsedToday;
/// Режим диагностики: нативный слой ловит КАЖДЫЙ пост от мониторимых
/// приложений (включая пустые/повторные) и снимает полный дамп `extras` в
/// `raw_messages.diagnostics`. По умолчанию выключен (отладка проблемы с
/// «Конфиденциально»).
final bool diagnosticModeEnabled;
bool get dailyLimitReached =>
aiDailyTokenLimit != null && tokensUsedToday >= aiDailyTokenLimit!;
@@ -48,6 +55,7 @@ class ParsingSettings {
int? aiDailyTokenLimit,
bool clearDailyLimit = false,
int? tokensUsedToday,
bool? diagnosticModeEnabled,
}) =>
ParsingSettings(
enabled: enabled ?? this.enabled,
@@ -57,6 +65,8 @@ class ParsingSettings {
aiDailyTokenLimit:
clearDailyLimit ? null : (aiDailyTokenLimit ?? this.aiDailyTokenLimit),
tokensUsedToday: tokensUsedToday ?? this.tokensUsedToday,
diagnosticModeEnabled:
diagnosticModeEnabled ?? this.diagnosticModeEnabled,
);
}
@@ -67,6 +77,7 @@ const _kAutoApply = 'parsing_auto_apply_enabled';
const _kAiConsent = 'ai_consent';
const _kAiModel = 'ai_model';
const _kAiDailyLimit = 'ai_daily_token_limit';
const _kDiagnosticMode = 'parsing_diagnostic_mode';
const kDefaultAiModel = 'deepseek-chat';
@@ -77,6 +88,7 @@ const _defaultSettings = ParsingSettings(
aiModel: kDefaultAiModel,
aiDailyTokenLimit: null,
tokensUsedToday: 0,
diagnosticModeEnabled: false,
);
/// Ключ дневного расхода токенов — с датой в имени, чтобы счётчик сам
@@ -99,6 +111,7 @@ class ParsingSettingsController extends _$ParsingSettingsController {
final modelStr = await dao.getPreference(_kAiModel);
final limitStr = await dao.getPreference(_kAiDailyLimit);
final usageStr = await dao.getPreference(_tokenUsageKey());
final diagnosticStr = await dao.getPreference(_kDiagnosticMode);
return ParsingSettings(
enabled:
@@ -112,6 +125,7 @@ class ParsingSettingsController extends _$ParsingSettingsController {
: kDefaultAiModel,
aiDailyTokenLimit: int.tryParse(limitStr ?? ''),
tokensUsedToday: int.tryParse(usageStr ?? '') ?? 0,
diagnosticModeEnabled: diagnosticStr == 'true',
);
}
@@ -130,6 +144,12 @@ class ParsingSettingsController extends _$ParsingSettingsController {
state = AsyncData(current.copyWith(autoApplyEnabled: value));
}
Future<void> setDiagnosticMode(bool value) async {
await _set(_kDiagnosticMode, '$value');
final current = state.value ?? _defaultSettings;
state = AsyncData(current.copyWith(diagnosticModeEnabled: value));
}
Future<void> setAiConsent(bool value) async {
await _set(_kAiConsent, '$value');
final current = state.value ?? _defaultSettings;
@@ -5,6 +5,7 @@ import '../domain/enums.dart';
import 'ai_providers.dart';
import 'notification_parsing_providers.dart';
import 'parsing_pipeline.dart';
import 'parsing_settings_controller.dart';
part 'parsing_worker.g.dart';
@@ -35,14 +36,32 @@ class ParsingWorker extends _$ParsingWorker {
pendingMessagesProvider(userId),
(_, next) {
final list = next.value;
if (list != null && list.isNotEmpty) {
_drain(userId, list);
if (list == null || list.isEmpty) return;
// Фича выключена: pipeline всё равно no-op'нул бы каждое сообщение —
// не прогоняем растущий pending заново на каждую новую вставку.
// Обратное включение тумблера запускает явный drain (см. enabledSub).
if (ref.read(parsingSettingsControllerProvider).value?.enabled ==
false) {
return;
}
_drain(userId, list);
},
fireImmediately: true,
);
ref.onDispose(sub.close);
// Размораживаем очередь при обратном включении фичи: raw_messages не
// менялись, поэтому pending-стрим сам не переэмитит застрявшие сообщения.
final enabledSub = ref.listen(
parsingSettingsControllerProvider,
(prev, next) {
if (next.value?.enabled == true && prev?.value?.enabled == false) {
_drainCurrentPending(userId);
}
},
);
ref.onDispose(enabledSub.close);
// Retry `pending_ai` при восстановлении сети (§7).
final onlineSub = ref.listen(
isOnlineProvider,
@@ -58,6 +77,16 @@ class ParsingWorker extends _$ParsingWorker {
ref.onDispose(onlineSub.close);
}
/// Drain текущего снапшота `pending` — явный триггер при обратном включении
/// фичи. Pending-стрим сам не эмитит (строки raw_messages не менялись),
/// поэтому читаем снапшот напрямую из репозитория (`.first`), как делает
/// pipeline для своих снапшотов.
Future<void> _drainCurrentPending(String userId) async {
final list =
await ref.read(rawMessagesRepositoryProvider).watchPending(userId).first;
if (list.isNotEmpty) _drain(userId, list);
}
/// Переводит ожидающие сети `pending_ai` обратно в `pending` (retry).
Future<void> _requeuePendingAi(String userId) async {
final repo = ref.read(rawMessagesRepositoryProvider);
@@ -32,6 +32,11 @@ class RawMessagesTable extends Table {
/// Кешированный JSON-снимок ParseDraft + RuleSuggestion (для Inbox).
TextColumn get draftJson => text().nullable()();
/// Дамп нативного уведомления для отладки (режим диагностики): `key`,
/// `postTime`, `visibility`, `flags` и все поля `extras`. Null в обычном
/// режиме. Показывается в детальной панели «Журнала парсинга».
TextColumn get diagnostics => text().nullable()();
// Per-field confidence scores (0100):
IntColumn get confidenceAmount => integer().nullable()();
IntColumn get confidenceAccount => integer().nullable()();
@@ -15,6 +15,7 @@ extension RawMessageMapper on RawMessagesTableData {
parseAttemptCount: parseAttemptCount,
lastParseError: lastParseError,
draftJson: draftJson,
diagnostics: diagnostics,
confidenceAmount: confidenceAmount,
confidenceAccount: confidenceAccount,
confidenceType: confidenceType,
@@ -24,12 +24,16 @@ class NativeNotification {
this.title,
required this.body,
required this.receivedAt,
this.diagnostics,
});
final String packageName;
final String? title;
final String body;
final DateTime receivedAt;
/// Дамп `extras`/`key`/`postTime` — заполняется только в режиме диагностики.
final String? diagnostics;
}
/// Обёртка платформенных каналов нативного слушателя уведомлений (Android).
@@ -69,6 +73,13 @@ class NotificationListenerChannel {
);
}
/// Включает/выключает режим диагностики на нативной стороне (ловить каждый
/// пост от мониторимых приложений и снимать дамп extras).
Future<void> setDiagnosticMode(bool enabled) async {
if (!_supported) return;
await _method.invokeMethod<void>('setDiagnosticMode', enabled);
}
/// Забирает и очищает нативную очередь пойманных уведомлений.
Future<List<NativeNotification>> drainPending() async {
if (!_supported) return const [];
@@ -84,8 +95,14 @@ class NotificationListenerChannel {
receivedAt: DateTime.fromMillisecondsSinceEpoch(
(map['receivedAt'] as num?)?.toInt() ?? 0,
),
diagnostics: map['diagnostics'] as String?,
);
}).where((n) => n.packageName.isNotEmpty && n.body.isNotEmpty).toList();
// В режиме диагностики body может быть пустым/синтетическим — такие посты
// пропускаем, если есть дамп (diagnostics != null), чтобы увидеть их в
// «Журнале парсинга».
}).where((n) =>
n.packageName.isNotEmpty &&
(n.body.isNotEmpty || n.diagnostics != null)).toList();
}
/// Список запускаемых приложений устройства (для выбора источника вручную).
@@ -16,8 +16,13 @@ const Duration kDedupWindow = Duration(minutes: 3);
/// себе хэш дублем не считается: вставка дополнительно проверяет
/// `receivedAt` в пределах [kDedupWindow], иначе регулярные одинаковые
/// операции (подписка, ежедневный кофе) терялись бы навсегда.
String computeDedupHash(String packageName, String body) {
final input = '$packageName\n$body';
///
/// [salt] подмешивается во вход, когда нужно различать иначе-идентичные посты
/// (режим диагностики солит `postTime`: переотправка при ребуте сохраняет тот
/// же `postTime` и схлопывается, а два разных поста с заглушкой «Конфиденциально»
/// имеют разный `postTime` и оба сохраняются).
String computeDedupHash(String packageName, String body, {String? salt}) {
final input = salt == null ? '$packageName\n$body' : '$packageName\n$body\n$salt';
return _fnv1a64(input);
}
@@ -63,8 +63,18 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
String? title,
required String body,
required DateTime receivedAt,
String? diagnostics,
}) async {
final hash = computeDedupHash(packageName, body);
// В режиме диагностики солим хэш `postTime`, чтобы каждый отдельный пост
// (заглушка «Конфиденциально» → апдейт) сохранялся отдельной строкой, но
// настоящая переотправка (ребут — тот же postTime) по-прежнему схлопывалась.
final hash = computeDedupHash(
packageName,
body,
salt: diagnostics == null
? null
: receivedAt.millisecondsSinceEpoch.toString(),
);
// §15: дубль = тот же хэш И receivedAt в пределах ±kDedupWindow. Тот же
// текст вне окна — новая реальная операция (подписка, регулярный платёж).
final existing = await findByDedupHash(userId, hash, receivedAt);
@@ -81,6 +91,7 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
receivedAt: receivedAt,
dedupHash: hash,
status: const Value(RawMessageStatus.pending),
diagnostics: Value(diagnostics),
),
);
final row = await _dao.findById(id);
@@ -29,6 +29,10 @@ abstract class RawMessage with _$RawMessage {
/// Заполняется перед отправкой в Inbox, чтобы пережить пересоздание виджета.
String? draftJson,
/// Дамп нативного уведомления (режим диагностики): key/postTime/extras.
/// Null в обычном режиме.
String? diagnostics,
// Per-field confidence scores (0100). Null = ещё не посчитан.
int? confidenceAmount,
int? confidenceAccount,
@@ -37,12 +37,16 @@ abstract interface class RawMessagesRepository {
/// dedup-хэшем уже есть **в пределах дедуп-окна по [receivedAt]** —
/// возвращает существующее, не создавая дубль. Тот же текст вне окна
/// (регулярный платёж) вставляется как новое сообщение.
/// [diagnostics] != null означает захват в режиме диагностики: дедуп солится
/// `receivedAt` (постимся каждый раз, кроме настоящей переотправки с тем же
/// `postTime`), а дамп сохраняется для «Журнала парсинга».
Future<RawMessage> insertIncoming({
required String userId,
required String packageName,
String? title,
required String body,
required DateTime receivedAt,
String? diagnostics,
});
Future<void> updateStatus(String id, RawMessageStatus status);
@@ -410,6 +410,22 @@ class _DetailPanel extends StatelessWidget {
_kv(context, l10n.parsingDetailCreatedAt,
_dateFmt.format(message.createdAt)),
]),
_section(context, l10n.parsingDetailDiagnostics, [
if (message.diagnostics != null &&
message.diagnostics!.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: SelectableText(
message.diagnostics!,
style: TextStyle(
fontSize: 11,
height: 1.35,
color: p.ink,
fontFamily: 'monospace',
),
),
),
]),
],
),
);
@@ -75,6 +75,26 @@ class ParsingSettingsScreen extends ConsumerWidget {
),
],
),
if (defaultTargetPlatform == TargetPlatform.android) ...[
const SizedBox(height: 16),
_Card(
children: [
SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
secondary: Icon(Icons.biotech_outlined, color: p.ink2),
title: Text(l10n.parsingDiagnosticModeLabel,
style: TextStyle(fontSize: 14, color: p.ink)),
subtitle: Text(l10n.parsingDiagnosticModeHint,
style: TextStyle(fontSize: 12, color: p.ink2)),
value: settings?.diagnosticModeEnabled ?? false,
activeThumbColor: p.accent,
onChanged: settings == null
? null
: (v) => controller.setDiagnosticMode(v),
),
],
),
],
const SizedBox(height: 16),
_Card(
children: [