From eed9164150b1b0e756ee6834dc7a97ae4b3a210d Mon Sep 17 00:00:00 2001 From: Aleksandr Mineev Date: Mon, 20 Jul 2026 22:04:39 +0300 Subject: [PATCH] Add CompanionDevice watch pairing and store raw AI responses Two fixes for the notification-parsing pipeline: - CompanionDeviceManager pairing (DEVICE_PROFILE_WATCH -> COMPANION_DEVICE_WATCH role -> RECEIVE_SENSITIVE_NOTIFICATIONS) via a new platform channel in MainActivity.kt, companion_device_channel.dart and companion_access_controller, surfaced as a card in parsing settings. Lifts the system "Confidential" redaction that hid VTB/T-Bank notification text from the listener. - raw_messages.ai_response: the model's raw completion content is now persisted (schema v4 + migration) and shown in the parsing log detail panel. Unlike draftJson it survives inbox sweeps and is filled even for partial/ignored outcomes. flutter analyze: no issues. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 5 + android/app/src/main/AndroidManifest.xml | 11 + .../sanders/budget/new_budget/MainActivity.kt | 199 ++++++++++++++++++ lib/l10n/app_en.arb | 10 + lib/l10n/app_localizations.dart | 54 +++++ lib/l10n/app_localizations_en.dart | 32 +++ lib/l10n/app_localizations_ru.dart | 33 +++ lib/l10n/app_ru.arb | 10 + lib/src/core/database/app_database.dart | 8 +- .../companion_access_controller.dart | 44 ++++ .../notification_parsing_providers.dart | 7 + .../application/parsing_pipeline.dart | 6 + .../data/drift/daos/raw_messages_dao.dart | 6 + .../data/drift/tables/raw_messages_table.dart | 6 + .../data/mappers/raw_message_mapper.dart | 1 + .../data/native/companion_device_channel.dart | 95 +++++++++ .../data/parser/ai_parser.dart | 35 +-- .../raw_messages_repository_impl.dart | 4 + .../domain/entities/raw_message.dart | 4 + .../repositories/raw_messages_repository.dart | 3 + .../screens/parsing_log_screen.dart | 15 ++ .../screens/parsing_settings_screen.dart | 184 ++++++++++++++++ test/core/database/migration_v2_test.dart | 27 +++ test/core/database/migration_v3_test.dart | 27 +++ .../parsing_pipeline_per_app_test.dart | 4 + 25 files changed, 817 insertions(+), 13 deletions(-) create mode 100644 lib/src/features/notification_parsing/application/companion_access_controller.dart create mode 100644 lib/src/features/notification_parsing/data/native/companion_device_channel.dart diff --git a/CLAUDE.md b/CLAUDE.md index 34abc43..997bb1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,11 @@ lib/ # tickerText when extras body is empty or a "содержимое скрыто" # stub — VTB puts the real text ONLY in ticker, no second post), # NotificationIngestWorker + ParsingWorker. + # data/native/companion_device_channel.dart + канал в + # MainActivity.kt: привязка «часов» через CompanionDeviceManager + # (DEVICE_PROFILE_WATCH → роль COMPANION_DEVICE_WATCH → + # RECEIVE_SENSITIVE_NOTIFICATIONS, снимает заглушку + # «Конфиденциально»); карточка в parsing_settings. # Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log profile/ # theme switcher screen shared/ diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 746e915..57ab3a8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,17 @@ + + + + + + when (call.method) { + "getStatus" -> result.success(companionStatus()) + "getBondedDevices" -> getBondedDevices(result) + "requestAssociation" -> + requestAssociation(call.argument("address"), result) + else -> result.notImplemented() + } + } + } + + /** + * unsupported | notBound | bound. Профили ассоциаций читаемы с API 33 + * (`myAssociations`); на 31–32 профиль недоступен — считаем привязанной + * любую ассоциацию (других мы не создаём). + */ + private fun companionStatus(): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return "unsupported" + val cdm = getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager + val bound = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + cdm.myAssociations.any { + it.deviceProfile == AssociationRequest.DEVICE_PROFILE_WATCH + } + } else { + @Suppress("DEPRECATION") + cdm.associations.isNotEmpty() + } + return if (bound) "bound" else "notBound" + } + + /** + * Сопряжённые Bluetooth-устройства для in-app пикера часов: [{name, + * address, isWearable}]. Чтение имён требует runtime-разрешения + * BLUETOOTH_CONNECT — при отсутствии запрашиваем его и отвечаем из + * [onRequestPermissionsResult]. + */ + private fun getBondedDevices(result: MethodChannel.Result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + result.success(emptyList>()) + return + } + if (checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != + PackageManager.PERMISSION_GRANTED + ) { + if (pendingBondedResult != null) { + result.error("in_progress", "Permission request is already pending", null) + return + } + pendingBondedResult = result + requestPermissions( + arrayOf(Manifest.permission.BLUETOOTH_CONNECT), REQUEST_BT_CONNECT, + ) + return + } + result.success(bondedDevices()) + } + + private fun bondedDevices(): List> { + val adapter = + (getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter + ?: return emptyList() + return try { + adapter.bondedDevices.map { + mapOf( + "name" to (it.name ?: ""), + "address" to it.address, + "isWearable" to (it.bluetoothClass?.majorDeviceClass == + BluetoothClass.Device.Major.WEARABLE), + ) + } + } catch (_: SecurityException) { + emptyList() + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + if (requestCode == REQUEST_BT_CONNECT) { + val r = pendingBondedResult + pendingBondedResult = null + if (r != null) { + if (grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + ) { + r.success(bondedDevices()) + } else { + r.error("bt_permission_denied", "BLUETOOTH_CONNECT denied", null) + } + } + return + } + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + /** + * Запускает привязку. С [address] (выбор из in-app пикера) — фильтр по + * адресу + setSingleDevice: система показывает простой диалог + * подтверждения с именем устройства вместо списка сканирования с + * MAC-адресами. Без адреса — общий системный поиск (фолбэк). Резолвится + * в true после успешной привязки, false — если пользователь закрыл диалог. + */ + private fun requestAssociation(address: String?, result: MethodChannel.Result) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + result.error("unsupported", "Device profiles require Android 12+", null) + return + } + if (pendingAssociationResult != null) { + result.error("in_progress", "Association dialog is already open", null) + return + } + val cdm = getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager + val builder = AssociationRequest.Builder() + .setDeviceProfile(AssociationRequest.DEVICE_PROFILE_WATCH) + if (address != null) { + builder + .addDeviceFilter( + BluetoothDeviceFilter.Builder().setAddress(address).build(), + ) + .setSingleDevice(true) + } + val request = builder.build() + pendingAssociationResult = result + // Старая сигнатура associate(request, callback, handler) работает на всех + // уровнях: на 33+ дефолтный onAssociationPending делегирует в onDeviceFound. + @Suppress("DEPRECATION") + cdm.associate( + request, + object : CompanionDeviceManager.Callback() { + @Deprecated("Deprecated in Java") + override fun onDeviceFound(chooserLauncher: IntentSender) { + try { + startIntentSenderForResult( + chooserLauncher, REQUEST_ASSOCIATE, null, 0, 0, 0, + ) + } catch (e: Exception) { + finishAssociation { it.error("launch_failed", e.message, null) } + } + } + + override fun onFailure(error: CharSequence?) { + finishAssociation { + it.error("associate_failed", error?.toString(), null) + } + } + }, + null, + ) + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == REQUEST_ASSOCIATE) { + finishAssociation { it.success(resultCode == Activity.RESULT_OK) } + return + } + super.onActivityResult(requestCode, resultCode, data) + } + + private fun finishAssociation(complete: (MethodChannel.Result) -> Unit) { + val r = pendingAssociationResult ?: return + pendingAssociationResult = null + complete(r) + } + + private companion object { + const val COMPANION_CHANNEL = "com.sanders.budget/companion" + const val REQUEST_ASSOCIATE = 0xC0DE + const val REQUEST_BT_CONNECT = 0xC0DF } } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5594597..a98ec04 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -235,6 +235,15 @@ "parsingNotifAccessGranted": "Granted — notifications are being read", "parsingNotifAccessDenied": "Not granted — tap to open settings", "parsingNotifAccessOpenSettings": "Open settings", + "parsingCompanionTitle": "Full notification access", + "parsingCompanionBound": "Watch linked — notification text arrives unredacted", + "parsingCompanionNotBound": "Android hides sensitive notification text. Link a watch to make the app trusted.", + "parsingCompanionBind": "Link watch", + "parsingCompanionBindFailed": "Failed to link: {error}", + "@parsingCompanionBindFailed": { "placeholders": { "error": { "type": "String" } } }, + "parsingCompanionPickerTitle": "Select your watch", + "parsingCompanionPickerScan": "Other device — system scan", + "parsingCompanionBtDenied": "Bluetooth permission denied — allow it in app settings", "parsingDebugSectionTitle": "Debug", "parsingDebugInjectTile": "Inject a test notification", @@ -269,6 +278,7 @@ "parsingDetailRule": "Rule suggestion", "parsingDetailMeta": "Meta", "parsingDetailDiagnostics": "Diagnostics", + "parsingDetailAiResponse": "AI response", "parsingDetailAttempt": "Attempt {count}/{max}", "@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } }, "parsingDetailSource": "Source", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 93ffbed..57d3b9c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1070,6 +1070,54 @@ abstract class AppLocalizations { /// **'Открыть настройки'** String get parsingNotifAccessOpenSettings; + /// No description provided for @parsingCompanionTitle. + /// + /// In ru, this message translates to: + /// **'Полный доступ к уведомлениям'** + String get parsingCompanionTitle; + + /// No description provided for @parsingCompanionBound. + /// + /// In ru, this message translates to: + /// **'Часы привязаны — текст уведомлений приходит целиком'** + String get parsingCompanionBound; + + /// No description provided for @parsingCompanionNotBound. + /// + /// In ru, this message translates to: + /// **'Android скрывает текст банковских уведомлений. Привяжите часы, чтобы приложение стало доверенным.'** + String get parsingCompanionNotBound; + + /// No description provided for @parsingCompanionBind. + /// + /// In ru, this message translates to: + /// **'Привязать часы'** + String get parsingCompanionBind; + + /// No description provided for @parsingCompanionBindFailed. + /// + /// In ru, this message translates to: + /// **'Не удалось привязать: {error}'** + String parsingCompanionBindFailed(String error); + + /// No description provided for @parsingCompanionPickerTitle. + /// + /// In ru, this message translates to: + /// **'Выберите ваши часы'** + String get parsingCompanionPickerTitle; + + /// No description provided for @parsingCompanionPickerScan. + /// + /// In ru, this message translates to: + /// **'Другое устройство — системный поиск'** + String get parsingCompanionPickerScan; + + /// No description provided for @parsingCompanionBtDenied. + /// + /// In ru, this message translates to: + /// **'Нет разрешения Bluetooth — выдайте его в настройках приложения'** + String get parsingCompanionBtDenied; + /// No description provided for @parsingDebugSectionTitle. /// /// In ru, this message translates to: @@ -1256,6 +1304,12 @@ abstract class AppLocalizations { /// **'Диагностика'** String get parsingDetailDiagnostics; + /// No description provided for @parsingDetailAiResponse. + /// + /// In ru, this message translates to: + /// **'Ответ ИИ'** + String get parsingDetailAiResponse; + /// No description provided for @parsingDetailAttempt. /// /// In ru, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0f131be..720f971 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -561,6 +561,35 @@ class AppLocalizationsEn extends AppLocalizations { @override String get parsingNotifAccessOpenSettings => 'Open settings'; + @override + String get parsingCompanionTitle => 'Full notification access'; + + @override + String get parsingCompanionBound => + 'Watch linked — notification text arrives unredacted'; + + @override + String get parsingCompanionNotBound => + 'Android hides sensitive notification text. Link a watch to make the app trusted.'; + + @override + String get parsingCompanionBind => 'Link watch'; + + @override + String parsingCompanionBindFailed(String error) { + return 'Failed to link: $error'; + } + + @override + String get parsingCompanionPickerTitle => 'Select your watch'; + + @override + String get parsingCompanionPickerScan => 'Other device — system scan'; + + @override + String get parsingCompanionBtDenied => + 'Bluetooth permission denied — allow it in app settings'; + @override String get parsingDebugSectionTitle => 'Debug'; @@ -655,6 +684,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get parsingDetailDiagnostics => 'Diagnostics'; + @override + String get parsingDetailAiResponse => 'AI response'; + @override String parsingDetailAttempt(int count, int max) { return 'Attempt $count/$max'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e7f3075..5b02fa5 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -573,6 +573,36 @@ class AppLocalizationsRu extends AppLocalizations { @override String get parsingNotifAccessOpenSettings => 'Открыть настройки'; + @override + String get parsingCompanionTitle => 'Полный доступ к уведомлениям'; + + @override + String get parsingCompanionBound => + 'Часы привязаны — текст уведомлений приходит целиком'; + + @override + String get parsingCompanionNotBound => + 'Android скрывает текст банковских уведомлений. Привяжите часы, чтобы приложение стало доверенным.'; + + @override + String get parsingCompanionBind => 'Привязать часы'; + + @override + String parsingCompanionBindFailed(String error) { + return 'Не удалось привязать: $error'; + } + + @override + String get parsingCompanionPickerTitle => 'Выберите ваши часы'; + + @override + String get parsingCompanionPickerScan => + 'Другое устройство — системный поиск'; + + @override + String get parsingCompanionBtDenied => + 'Нет разрешения Bluetooth — выдайте его в настройках приложения'; + @override String get parsingDebugSectionTitle => 'Отладка'; @@ -667,6 +697,9 @@ class AppLocalizationsRu extends AppLocalizations { @override String get parsingDetailDiagnostics => 'Диагностика'; + @override + String get parsingDetailAiResponse => 'Ответ ИИ'; + @override String parsingDetailAttempt(int count, int max) { return 'Попытка $count/$max'; diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 27fb12d..2012716 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -235,6 +235,15 @@ "parsingNotifAccessGranted": "Выдан — уведомления читаются", "parsingNotifAccessDenied": "Не выдан — нажмите, чтобы открыть настройки", "parsingNotifAccessOpenSettings": "Открыть настройки", + "parsingCompanionTitle": "Полный доступ к уведомлениям", + "parsingCompanionBound": "Часы привязаны — текст уведомлений приходит целиком", + "parsingCompanionNotBound": "Android скрывает текст банковских уведомлений. Привяжите часы, чтобы приложение стало доверенным.", + "parsingCompanionBind": "Привязать часы", + "parsingCompanionBindFailed": "Не удалось привязать: {error}", + "@parsingCompanionBindFailed": { "placeholders": { "error": { "type": "String" } } }, + "parsingCompanionPickerTitle": "Выберите ваши часы", + "parsingCompanionPickerScan": "Другое устройство — системный поиск", + "parsingCompanionBtDenied": "Нет разрешения Bluetooth — выдайте его в настройках приложения", "parsingDebugSectionTitle": "Отладка", "parsingDebugInjectTile": "Вставить тестовое уведомление", @@ -269,6 +278,7 @@ "parsingDetailRule": "Предложение правила", "parsingDetailMeta": "Мета", "parsingDetailDiagnostics": "Диагностика", + "parsingDetailAiResponse": "Ответ ИИ", "parsingDetailAttempt": "Попытка {count}/{max}", "@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } }, "parsingDetailSource": "Источник", diff --git a/lib/src/core/database/app_database.dart b/lib/src/core/database/app_database.dart index f870cb5..04a58cd 100644 --- a/lib/src/core/database/app_database.dart +++ b/lib/src/core/database/app_database.dart @@ -67,7 +67,7 @@ class AppDatabase extends _$AppDatabase { AppDatabase.forTesting(super.executor); @override - int get schemaVersion => 3; + int get schemaVersion => 4; @override MigrationStrategy get migration => MigrationStrategy( @@ -77,9 +77,15 @@ class AppDatabase extends _$AppDatabase { onUpgrade: (m, from, to) async { if (from < 2) await _migrateV1ToV2(m); if (from < 3) await _migrateV2ToV3(m); + if (from < 4) await _migrateV3ToV4(m); }, ); + /// v3 → v4: `raw_messages.ai_response` — сырой ответ DeepSeek для журнала. + Future _migrateV3ToV4(Migrator m) async { + await m.addColumn(rawMessagesTable, rawMessagesTable.aiResponse); + } + /// v2 → v3: единая модель правил — колонка `kind` исчезает, появляются /// `is_ignore` и `auto_apply` (план «Правила парсинга: единая модель»): /// - kind='ignore' → is_ignore=1; diff --git a/lib/src/features/notification_parsing/application/companion_access_controller.dart b/lib/src/features/notification_parsing/application/companion_access_controller.dart new file mode 100644 index 0000000..bdde346 --- /dev/null +++ b/lib/src/features/notification_parsing/application/companion_access_controller.dart @@ -0,0 +1,44 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../data/native/companion_device_channel.dart'; +import 'notification_parsing_providers.dart'; + +part 'companion_access_controller.g.dart'; + +/// Статус привязки «часов» через CompanionDeviceManager (Android; на прочих +/// платформах — unsupported) + действия: запустить системный диалог привязки +/// и перечитать статус. +@riverpod +class CompanionAccessController extends _$CompanionAccessController { + @override + Future build() => + ref.watch(companionDeviceChannelProvider).getStatus(); + + /// Сопряжённые Bluetooth-устройства для пикера часов (wearable — первыми); + /// нативная сторона при необходимости запрашивает BLUETOOTH_CONNECT. + Future> bondedDevices() => + ref.read(companionDeviceChannelProvider).getBondedDevices(); + + /// Показывает системный диалог привязки (с [address] — подтверждение + /// конкретного устройства, без — общий поиск) и перечитывает статус. + /// Возвращает true после успешной привязки, false — если пользователь + /// закрыл диалог; ошибки ассоциации пробрасываются наверх. + Future requestAssociation({String? address}) async { + try { + return await ref + .read(companionDeviceChannelProvider) + .requestAssociation(address: address); + } finally { + await refresh(); + } + } + + /// Перечитывает статус привязки (например, после возврата из диалога). + /// `state = ...` вместо `ref.invalidate`: не задействует vsync-планировщик + /// Riverpod, поэтому безопасно вызывать из `didChangeAppLifecycleState`. + Future refresh() async { + state = await AsyncValue.guard( + () => ref.read(companionDeviceChannelProvider).getStatus(), + ); + } +} diff --git a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart index b7a7032..4626bc5 100644 --- a/lib/src/features/notification_parsing/application/notification_parsing_providers.dart +++ b/lib/src/features/notification_parsing/application/notification_parsing_providers.dart @@ -1,6 +1,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../core/providers/database_provider.dart'; +import '../data/native/companion_device_channel.dart'; import '../data/native/notification_listener_channel.dart'; import '../data/repositories/parse_rules_repository_impl.dart'; import '../data/repositories/raw_messages_repository_impl.dart'; @@ -54,6 +55,12 @@ Stream> enabledSourcePackages(Ref ref, String userId) => NotificationListenerChannel notificationListenerChannel(Ref ref) => NotificationListenerChannel(); +/// Платформенный канал CompanionDeviceManager — привязка «часов» для полного +/// доступа к тексту уведомлений (Android). +@Riverpod(keepAlive: true) +CompanionDeviceChannel companionDeviceChannel(Ref ref) => + CompanionDeviceChannel(); + /// Список установленных запускаемых приложений (для экрана выбора источников). /// autoDispose: грузится при открытии пикера, освобождается после закрытия. @riverpod diff --git a/lib/src/features/notification_parsing/application/parsing_pipeline.dart b/lib/src/features/notification_parsing/application/parsing_pipeline.dart index 035e72d..a6a2674 100644 --- a/lib/src/features/notification_parsing/application/parsing_pipeline.dart +++ b/lib/src/features/notification_parsing/application/parsing_pipeline.dart @@ -188,6 +188,12 @@ class ParsingPipeline { .read(parsingSettingsControllerProvider.notifier) .addTokenUsage(outcome.tokensUsed); } + // Сырой ответ модели — до ветвления, чтобы попасть в журнал при любом + // исходе (draft/partial/ignored). + final rawContent = outcome.rawContent; + if (rawContent != null && rawContent.isNotEmpty) { + await repo.setAiResponse(msg.id, rawContent); + } switch (outcome.status) { case AiParseStatus.ignored: await repo.updateStatus(msg.id, RawMessageStatus.ignored); diff --git a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart index e3f0dc6..8810041 100644 --- a/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart +++ b/lib/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart @@ -175,6 +175,12 @@ LIMIT ?4 RawMessagesTableCompanion(status: Value(status)), ); + /// Сохранение сырого ответа AI (только эта колонка, статус не трогается). + Future setAiResponse(String id, String content) => + (update(rawMessagesTable)..where((t) => t.id.equals(id))).write( + RawMessagesTableCompanion(aiResponse: Value(content)), + ); + /// Запись результатов парсинга (draft + confidence-оценки). /// /// [lastParseError] пишется только если передан (диагностика diff --git a/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart b/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart index d0a6e8b..a0c918a 100644 --- a/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart +++ b/lib/src/features/notification_parsing/data/drift/tables/raw_messages_table.dart @@ -37,6 +37,12 @@ class RawMessagesTable extends Table { /// режиме. Показывается в детальной панели «Журнала парсинга». TextColumn get diagnostics => text().nullable()(); + /// Сырой `completion.content` последнего AI-разбора — как есть, до + /// извлечения JSON. Null, пока AI не вызывался. Показывается в детальной + /// панели «Журнала парсинга» (в отличие от [draftJson] не перезаписывается + /// sweep'ом и заполнен даже при исходах partial/ignored). + TextColumn get aiResponse => text().nullable()(); + // Per-field confidence scores (0–100): IntColumn get confidenceAmount => integer().nullable()(); IntColumn get confidenceAccount => integer().nullable()(); diff --git a/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart b/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart index a6aaa31..76227ea 100644 --- a/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart +++ b/lib/src/features/notification_parsing/data/mappers/raw_message_mapper.dart @@ -16,6 +16,7 @@ extension RawMessageMapper on RawMessagesTableData { lastParseError: lastParseError, draftJson: draftJson, diagnostics: diagnostics, + aiResponse: aiResponse, confidenceAmount: confidenceAmount, confidenceAccount: confidenceAccount, confidenceType: confidenceType, diff --git a/lib/src/features/notification_parsing/data/native/companion_device_channel.dart b/lib/src/features/notification_parsing/data/native/companion_device_channel.dart new file mode 100644 index 0000000..a071bb7 --- /dev/null +++ b/lib/src/features/notification_parsing/data/native/companion_device_channel.dart @@ -0,0 +1,95 @@ +import 'dart:io' show Platform; + +import 'package:flutter/services.dart'; + +/// Статус привязки companion-устройства («часы») для полного доступа +/// к тексту уведомлений. +enum CompanionAccessStatus { + /// Платформа не поддерживает профили CDM (не Android или Android < 12). + unsupported, + + /// Привязки нет — система может скрывать текст банковских уведомлений. + notBound, + + /// Часы привязаны — приложение имеет RECEIVE_SENSITIVE_NOTIFICATIONS. + bound, +} + +/// Сопряжённое Bluetooth-устройство (для in-app пикера часов). +class BondedBluetoothDevice { + const BondedBluetoothDevice({ + required this.name, + required this.address, + required this.isWearable, + }); + + /// Имя устройства; может быть пустым — тогда UI показывает [address]. + final String name; + + /// MAC-адрес — идентификатор для фильтра ассоциации. + final String address; + + /// Bluetooth-класс WEARABLE — такие устройства показываем первыми. + final bool isWearable; +} + +/// Обёртка платформенного канала CompanionDeviceManager (Android). +/// +/// Привязка устройства по профилю «часы» выдаёт приложению роль +/// COMPANION_DEVICE_WATCH и с ней appop RECEIVE_SENSITIVE_NOTIFICATIONS: +/// система перестаёт подменять текст банковских уведомлений заглушкой +/// «Конфиденциально» для нашего notification listener. На не-Android +/// платформах (и в `flutter test`) всё no-op. +class CompanionDeviceChannel { + static const MethodChannel _method = + MethodChannel('com.sanders.budget/companion'); + + bool get _supported => Platform.isAndroid; + + /// Текущий статус привязки. + Future getStatus() async { + if (!_supported) return CompanionAccessStatus.unsupported; + final raw = await _method.invokeMethod('getStatus'); + return switch (raw) { + 'bound' => CompanionAccessStatus.bound, + 'notBound' => CompanionAccessStatus.notBound, + _ => CompanionAccessStatus.unsupported, + }; + } + + /// Сопряжённые Bluetooth-устройства (wearable — первыми). Нативная сторона + /// при первом вызове запрашивает runtime-разрешение BLUETOOTH_CONNECT; + /// отказ — [PlatformException] с кодом `bt_permission_denied`. + Future> getBondedDevices() async { + if (!_supported) return const []; + final raw = await _method.invokeListMethod('getBondedDevices'); + if (raw == null) return const []; + final devices = raw.map((e) { + final map = (e as Map).cast(); + return BondedBluetoothDevice( + name: (map['name'] as String?) ?? '', + address: (map['address'] as String?) ?? '', + isWearable: (map['isWearable'] as bool?) ?? false, + ); + }).where((d) => d.address.isNotEmpty).toList() + ..sort((a, b) { + if (a.isWearable != b.isWearable) return a.isWearable ? -1 : 1; + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + return devices; + } + + /// Показывает системный диалог привязки. С [address] (устройство выбрано в + /// in-app пикере) система показывает простое подтверждение с именем + /// устройства; без адреса — общий поиск. Возвращает true после успешной + /// привязки, false — если пользователь закрыл диалог; бросает + /// [PlatformException] при ошибке ассоциации. + Future requestAssociation({String? address}) async { + if (!_supported) return false; + final ok = await _method.invokeMethod( + 'requestAssociation', + {'address': address}, + ); + return ok ?? false; + } +} diff --git a/lib/src/features/notification_parsing/data/parser/ai_parser.dart b/lib/src/features/notification_parsing/data/parser/ai_parser.dart index 0bb7c21..d409967 100644 --- a/lib/src/features/notification_parsing/data/parser/ai_parser.dart +++ b/lib/src/features/notification_parsing/data/parser/ai_parser.dart @@ -29,19 +29,21 @@ class AiParser { ); final tokens = completion.totalTokens; - final json = extractJsonObject(completion.content); - if (json == null) return AiParseOutcome.partial(tokens); + final content = completion.content; + + final json = extractJsonObject(content); + if (json == null) return AiParseOutcome.partial(tokens, rawContent: content); final typeStr = (json['type'] as String?)?.toLowerCase(); final kind = _kindFrom(json['kind'] as String?); if (typeStr == 'ignored' || kind == TxKind.balance) { - return AiParseOutcome.ignored(tokens); + return AiParseOutcome.ignored(tokens, rawContent: content); } final type = _typeFrom(typeStr); final amount = _amountMinor(json['amount']); if (type == null || amount == null || amount <= 0) { - return AiParseOutcome.partial(tokens); + return AiParseOutcome.partial(tokens, rawContent: content); } final draft = ParseDraft( @@ -60,7 +62,7 @@ class AiParser { categorySuggestion: _str(json['categorySuggestion']), source: ParseSource.ai, ); - return AiParseOutcome.draft(draft, tokens); + return AiParseOutcome.draft(draft, tokens, rawContent: content); } TransactionType? _typeFrom(String? s) => switch (s) { @@ -107,18 +109,27 @@ class AiParser { enum AiParseStatus { draft, partial, ignored } class AiParseOutcome { - const AiParseOutcome._(this.status, this.draft, this.tokensUsed); + const AiParseOutcome._(this.status, this.draft, this.tokensUsed, + {this.rawContent}); final AiParseStatus status; final ParseDraft? draft; final int tokensUsed; - factory AiParseOutcome.draft(ParseDraft draft, int tokens) => - AiParseOutcome._(AiParseStatus.draft, draft, tokens); + /// Сырой `completion.content` модели (до извлечения JSON) — сохраняется + /// в `raw_messages.aiResponse` для «Журнала парсинга». + final String? rawContent; - factory AiParseOutcome.partial(int tokens) => - AiParseOutcome._(AiParseStatus.partial, null, tokens); + factory AiParseOutcome.draft(ParseDraft draft, int tokens, + {String? rawContent}) => + AiParseOutcome._(AiParseStatus.draft, draft, tokens, + rawContent: rawContent); - factory AiParseOutcome.ignored(int tokens) => - AiParseOutcome._(AiParseStatus.ignored, null, tokens); + factory AiParseOutcome.partial(int tokens, {String? rawContent}) => + AiParseOutcome._(AiParseStatus.partial, null, tokens, + rawContent: rawContent); + + factory AiParseOutcome.ignored(int tokens, {String? rawContent}) => + AiParseOutcome._(AiParseStatus.ignored, null, tokens, + rawContent: rawContent); } diff --git a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart index 9581074..95cb082 100644 --- a/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart +++ b/lib/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart @@ -124,6 +124,10 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository { Future updateStatus(String id, RawMessageStatus status) => _dao.updateStatus(id, status); + @override + Future setAiResponse(String id, String content) => + _dao.setAiResponse(id, content); + @override Future updateAfterParse({ required String id, diff --git a/lib/src/features/notification_parsing/domain/entities/raw_message.dart b/lib/src/features/notification_parsing/domain/entities/raw_message.dart index 71eb6e5..df69e31 100644 --- a/lib/src/features/notification_parsing/domain/entities/raw_message.dart +++ b/lib/src/features/notification_parsing/domain/entities/raw_message.dart @@ -33,6 +33,10 @@ abstract class RawMessage with _$RawMessage { /// Null в обычном режиме. String? diagnostics, + /// Сырой `completion.content` последнего AI-разбора (до извлечения JSON). + /// Null, пока AI не вызывался. + String? aiResponse, + // Per-field confidence scores (0–100). Null = ещё не посчитан. int? confidenceAmount, int? confidenceAccount, diff --git a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart index a7fc088..b1b344d 100644 --- a/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart +++ b/lib/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart @@ -70,6 +70,9 @@ abstract interface class RawMessagesRepository { Future updateStatus(String id, RawMessageStatus status); + /// Сохранение сырого ответа AI (`completion.content`) — для журнала. + Future setAiResponse(String id, String content); + /// Запись результатов парсинга (draft + per-field confidence). Future updateAfterParse({ required String id, diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart index 1dd1dfc..13728e5 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_log_screen.dart @@ -434,6 +434,21 @@ class _DetailPanel extends StatelessWidget { _kv(context, l10n.parsingDetailRuleCategory, suggestion.categoryName), ]), + _section(context, l10n.parsingDetailAiResponse, [ + if (message.aiResponse != null && message.aiResponse!.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SelectableText( + message.aiResponse!, + style: TextStyle( + fontSize: 11, + height: 1.35, + color: p.ink, + fontFamily: 'monospace', + ), + ), + ), + ]), _section(context, l10n.parsingDetailMeta, [ _kv(context, l10n.parsingDetailTransactionId, message.transactionId, mono: true), diff --git a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart index f517ab5..90c0699 100644 --- a/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart +++ b/lib/src/features/notification_parsing/presentation/screens/parsing_settings_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show PlatformException; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -7,7 +8,9 @@ import '../../../../app/l10n/l10n.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../app/theme/app_colors.dart'; import '../../../user/application/active_user_controller.dart'; +import '../../application/companion_access_controller.dart'; import '../../application/notification_access_controller.dart'; +import '../../data/native/companion_device_channel.dart'; import '../../application/parsing_settings_controller.dart'; import '../../application/rules_controller.dart'; import '../../domain/entities/parse_rule.dart'; @@ -57,6 +60,8 @@ class ParsingSettingsScreen extends ConsumerWidget { if (defaultTargetPlatform == TargetPlatform.android) ...[ const SizedBox(height: 16), const _NotificationAccessCard(), + // Сама рисует свой верхний отступ, когда видима. + const _CompanionAccessCard(), ], const SizedBox(height: 16), _Card( @@ -270,6 +275,185 @@ class _NotificationAccessCardState } } +/// Карточка «Полный доступ к уведомлениям»: привязка «часов» через +/// CompanionDeviceManager (роль COMPANION_DEVICE_WATCH → +/// RECEIVE_SENSITIVE_NOTIFICATIONS — система перестаёт скрывать текст +/// банковских уведомлений). Скрыта, если платформа не поддерживает профили +/// CDM; статус перечитывается при возврате в приложение. +class _CompanionAccessCard extends ConsumerStatefulWidget { + const _CompanionAccessCard(); + + @override + ConsumerState<_CompanionAccessCard> createState() => + _CompanionAccessCardState(); +} + +class _CompanionAccessCardState extends ConsumerState<_CompanionAccessCard> + with WidgetsBindingObserver { + bool _requesting = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && mounted) { + ref.read(companionAccessControllerProvider.notifier).refresh(); + } + } + + Future _bind() async { + setState(() => _requesting = true); + try { + final controller = ref.read(companionAccessControllerProvider.notifier); + // In-app пикер сопряжённых устройств: система показывает лишь + // подтверждение с именем, а не список сканирования с MAC-адресами. + final devices = await controller.bondedDevices(); + if (!mounted) return; + String? address; + if (devices.isNotEmpty) { + final choice = await _pickDevice(devices); + if (choice == null) return; // пикер закрыт + address = choice.isEmpty ? null : choice; + } + await controller.requestAssociation(address: address); + } on PlatformException catch (e) { + if (mounted) { + final l10n = context.l10n; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e.code == 'bt_permission_denied' + ? l10n.parsingCompanionBtDenied + : l10n.parsingCompanionBindFailed(e.message ?? e.code), + ), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.parsingCompanionBindFailed('$e'))), + ); + } + } finally { + if (mounted) setState(() => _requesting = false); + } + } + + /// Возвращает адрес выбранного устройства, '' — «общий системный поиск», + /// null — пикер закрыт без выбора. + Future _pickDevice(List devices) { + final p = context.palette; + final l10n = context.l10n; + return showModalBottomSheet( + context: context, + backgroundColor: p.paper, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: p.line, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Text(l10n.parsingCompanionPickerTitle, + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: p.ink)), + ), + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + for (final d in devices) + ListTile( + leading: Icon( + d.isWearable ? Icons.watch_outlined : Icons.bluetooth, + color: p.ink2, + ), + title: Text(d.name.isEmpty ? d.address : d.name, + style: TextStyle(fontSize: 14, color: p.ink)), + onTap: () => Navigator.of(sheetContext).pop(d.address), + ), + ListTile( + leading: Icon(Icons.bluetooth_searching, color: p.ink2), + title: Text(l10n.parsingCompanionPickerScan, + style: TextStyle(fontSize: 14, color: p.ink2)), + onTap: () => Navigator.of(sheetContext).pop(''), + ), + ], + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final p = context.palette; + final l10n = context.l10n; + final status = ref.watch(companionAccessControllerProvider).value; + + if (status == null || status == CompanionAccessStatus.unsupported) { + return const SizedBox.shrink(); + } + final bound = status == CompanionAccessStatus.bound; + + return Column( + children: [ + const SizedBox(height: 16), + _Card( + children: [ + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 14), + leading: Icon( + Icons.watch_outlined, + color: bound ? p.positive : p.ink2, + ), + title: Text(l10n.parsingCompanionTitle, + style: TextStyle(fontSize: 14, color: p.ink)), + subtitle: Text( + bound + ? l10n.parsingCompanionBound + : l10n.parsingCompanionNotBound, + style: TextStyle(fontSize: 12, color: p.ink2), + ), + trailing: bound + ? null + : TextButton( + onPressed: _requesting ? null : _bind, + child: Text(l10n.parsingCompanionBind), + ), + ), + ], + ), + ], + ); + } +} + class _Card extends StatelessWidget { const _Card({required this.children}); final List children; diff --git a/test/core/database/migration_v2_test.dart b/test/core/database/migration_v2_test.dart index 19a5629..f4e5d6e 100644 --- a/test/core/database/migration_v2_test.dart +++ b/test/core/database/migration_v2_test.dart @@ -90,6 +90,33 @@ CREATE TABLE account_bindings ( '(id, user_id, phone, account_id) VALUES ' "('b6', '$_userId', '+79990001122', 'acc-4')"); + // Хвост цепочки (v3→v4) добавляет колонку в raw_messages — в реальной + // v1-БД таблица есть, здесь достаточно её минимальной схемы. + raw.execute(''' +CREATE TABLE raw_messages ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + package_name TEXT NOT NULL, + title TEXT, + body TEXT NOT NULL, + received_at INTEGER NOT NULL, + dedup_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + parse_attempt_count INTEGER NOT NULL DEFAULT 0, + last_parse_error TEXT, + draft_json TEXT, + diagnostics TEXT, + confidence_amount INTEGER, + confidence_account INTEGER, + confidence_type INTEGER, + confidence_merchant INTEGER, + confidence_category INTEGER, + transaction_id TEXT, + paired_with_id TEXT, + pair_deadline INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +);'''); + raw.execute('PRAGMA user_version = 1'); } diff --git a/test/core/database/migration_v3_test.dart b/test/core/database/migration_v3_test.dart index 8044a6f..c4c9b23 100644 --- a/test/core/database/migration_v3_test.dart +++ b/test/core/database/migration_v3_test.dart @@ -61,6 +61,33 @@ CREATE TABLE parse_rules ( "('r-ignore', '$_userId', '$_sber', 'ignore', 'regex', " "'заказ \\d+', 0, 0, NULL, NULL, NULL, NULL, 0)"); + // Хвост цепочки (v3→v4) добавляет колонку в raw_messages — в реальной + // v2-БД таблица есть, здесь достаточно её минимальной схемы. + raw.execute(''' +CREATE TABLE raw_messages ( + id TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL, + package_name TEXT NOT NULL, + title TEXT, + body TEXT NOT NULL, + received_at INTEGER NOT NULL, + dedup_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + parse_attempt_count INTEGER NOT NULL DEFAULT 0, + last_parse_error TEXT, + draft_json TEXT, + diagnostics TEXT, + confidence_amount INTEGER, + confidence_account INTEGER, + confidence_type INTEGER, + confidence_merchant INTEGER, + confidence_category INTEGER, + transaction_id TEXT, + paired_with_id TEXT, + pair_deadline INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +);'''); + raw.execute('PRAGMA user_version = 2'); } diff --git a/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart b/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart index bdcc217..bb17a06 100644 --- a/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart +++ b/test/features/notification_parsing/application/parsing_pipeline_per_app_test.dart @@ -197,6 +197,10 @@ void main() { expect(msg.status, RawMessageStatus.inbox, reason: 'правило bankA не видно в bankB → нет auto-apply'); + // Сырой ответ модели сохранён для «Журнала парсинга». + expect(msg.aiResponse, isNotNull); + expect(msg.aiResponse, contains('LENTA')); + // Мерчант «незнакомый» в скоупе bankB → pipeline предлагает создать правило. final bundle = decodeDraftBundle(msg.draftJson); expect(bundle, isNotNull);