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 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 22:04:39 +03:00
co-authored by Claude Opus 4.8
parent f2ed501ed4
commit eed9164150
25 changed files with 817 additions and 13 deletions
+5
View File
@@ -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/
+11
View File
@@ -2,6 +2,17 @@
<!-- В release-манифест Flutter НЕ добавляет INTERNET автоматически (в отличие
от debug/profile) — без этой строки все сетевые вызовы падают. -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Привязка «часов» через CompanionDeviceManager (DEVICE_PROFILE_WATCH):
роль COMPANION_DEVICE_WATCH выдаёт RECEIVE_SENSITIVE_NOTIFICATIONS,
и система перестаёт скрывать текст банковских уведомлений
(«Конфиденциально») для нашего notification listener. -->
<uses-permission android:name="android.permission.REQUEST_COMPANION_PROFILE_WATCH"/>
<!-- Runtime-разрешение: чтение списка сопряжённых устройств для in-app
пикера часов (имена вместо MAC-адресов системного сканера). -->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-feature
android:name="android.software.companion_device_setup"
android:required="false"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"
@@ -1,12 +1,211 @@
package com.sanders.budget.new_budget
import android.Manifest
import android.app.Activity
import android.bluetooth.BluetoothClass
import android.bluetooth.BluetoothManager
import android.companion.AssociationRequest
import android.companion.BluetoothDeviceFilter
import android.companion.CompanionDeviceManager
import android.content.Context
import android.content.Intent
import android.content.IntentSender
import android.content.pm.PackageManager
import android.os.Build
import com.sanders.budget.new_budget.notifications.NotificationIngestPlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
/**
* Хост Flutter + канал CompanionDeviceManager.
*
* Привязка устройства по профилю «часы» (DEVICE_PROFILE_WATCH) выдаёт
* приложению роль COMPANION_DEVICE_WATCH и вместе с ней appop
* RECEIVE_SENSITIVE_NOTIFICATIONS — система перестаёт подменять текст
* банковских уведомлений заглушкой «Конфиденциально» для нашего listener.
* Флоу требует Activity (startIntentSenderForResult + onActivityResult),
* поэтому живёт здесь, а не в [NotificationIngestPlugin].
*/
class MainActivity : FlutterActivity() {
private var pendingAssociationResult: MethodChannel.Result? = null
private var pendingBondedResult: MethodChannel.Result? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
NotificationIngestPlugin.register(flutterEngine, this)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, COMPANION_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getStatus" -> result.success(companionStatus())
"getBondedDevices" -> getBondedDevices(result)
"requestAssociation" ->
requestAssociation(call.argument<String>("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<Map<String, Any?>>())
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<Map<String, Any?>> {
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<out String>,
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
}
}
+10
View File
@@ -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",
+54
View File
@@ -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:
+32
View File
@@ -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';
+33
View File
@@ -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';
+10
View File
@@ -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": "Источник",
+7 -1
View File
@@ -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<void> _migrateV3ToV4(Migrator m) async {
await m.addColumn(rawMessagesTable, rawMessagesTable.aiResponse);
}
/// v2 → v3: единая модель правил — колонка `kind` исчезает, появляются
/// `is_ignore` и `auto_apply` (план «Правила парсинга: единая модель»):
/// - kind='ignore' → is_ignore=1;
@@ -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<CompanionAccessStatus> build() =>
ref.watch(companionDeviceChannelProvider).getStatus();
/// Сопряжённые Bluetooth-устройства для пикера часов (wearable — первыми);
/// нативная сторона при необходимости запрашивает BLUETOOTH_CONNECT.
Future<List<BondedBluetoothDevice>> bondedDevices() =>
ref.read(companionDeviceChannelProvider).getBondedDevices();
/// Показывает системный диалог привязки (с [address] — подтверждение
/// конкретного устройства, без — общий поиск) и перечитывает статус.
/// Возвращает true после успешной привязки, false — если пользователь
/// закрыл диалог; ошибки ассоциации пробрасываются наверх.
Future<bool> requestAssociation({String? address}) async {
try {
return await ref
.read(companionDeviceChannelProvider)
.requestAssociation(address: address);
} finally {
await refresh();
}
}
/// Перечитывает статус привязки (например, после возврата из диалога).
/// `state = ...` вместо `ref.invalidate`: не задействует vsync-планировщик
/// Riverpod, поэтому безопасно вызывать из `didChangeAppLifecycleState`.
Future<void> refresh() async {
state = await AsyncValue.guard(
() => ref.read(companionDeviceChannelProvider).getStatus(),
);
}
}
@@ -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<Set<String>> enabledSourcePackages(Ref ref, String userId) =>
NotificationListenerChannel notificationListenerChannel(Ref ref) =>
NotificationListenerChannel();
/// Платформенный канал CompanionDeviceManager — привязка «часов» для полного
/// доступа к тексту уведомлений (Android).
@Riverpod(keepAlive: true)
CompanionDeviceChannel companionDeviceChannel(Ref ref) =>
CompanionDeviceChannel();
/// Список установленных запускаемых приложений (для экрана выбора источников).
/// autoDispose: грузится при открытии пикера, освобождается после закрытия.
@riverpod
@@ -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);
@@ -175,6 +175,12 @@ LIMIT ?4
RawMessagesTableCompanion(status: Value(status)),
);
/// Сохранение сырого ответа AI (только эта колонка, статус не трогается).
Future<void> setAiResponse(String id, String content) =>
(update(rawMessagesTable)..where((t) => t.id.equals(id))).write(
RawMessagesTableCompanion(aiResponse: Value(content)),
);
/// Запись результатов парсинга (draft + confidence-оценки).
///
/// [lastParseError] пишется только если передан (диагностика
@@ -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 (0100):
IntColumn get confidenceAmount => integer().nullable()();
IntColumn get confidenceAccount => integer().nullable()();
@@ -16,6 +16,7 @@ extension RawMessageMapper on RawMessagesTableData {
lastParseError: lastParseError,
draftJson: draftJson,
diagnostics: diagnostics,
aiResponse: aiResponse,
confidenceAmount: confidenceAmount,
confidenceAccount: confidenceAccount,
confidenceType: confidenceType,
@@ -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<CompanionAccessStatus> getStatus() async {
if (!_supported) return CompanionAccessStatus.unsupported;
final raw = await _method.invokeMethod<String>('getStatus');
return switch (raw) {
'bound' => CompanionAccessStatus.bound,
'notBound' => CompanionAccessStatus.notBound,
_ => CompanionAccessStatus.unsupported,
};
}
/// Сопряжённые Bluetooth-устройства (wearable — первыми). Нативная сторона
/// при первом вызове запрашивает runtime-разрешение BLUETOOTH_CONNECT;
/// отказ — [PlatformException] с кодом `bt_permission_denied`.
Future<List<BondedBluetoothDevice>> getBondedDevices() async {
if (!_supported) return const [];
final raw = await _method.invokeListMethod<dynamic>('getBondedDevices');
if (raw == null) return const [];
final devices = raw.map((e) {
final map = (e as Map).cast<dynamic, dynamic>();
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<bool> requestAssociation({String? address}) async {
if (!_supported) return false;
final ok = await _method.invokeMethod<bool>(
'requestAssociation',
{'address': address},
);
return ok ?? false;
}
}
@@ -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);
}
@@ -124,6 +124,10 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
Future<void> updateStatus(String id, RawMessageStatus status) =>
_dao.updateStatus(id, status);
@override
Future<void> setAiResponse(String id, String content) =>
_dao.setAiResponse(id, content);
@override
Future<void> updateAfterParse({
required String id,
@@ -33,6 +33,10 @@ abstract class RawMessage with _$RawMessage {
/// Null в обычном режиме.
String? diagnostics,
/// Сырой `completion.content` последнего AI-разбора (до извлечения JSON).
/// Null, пока AI не вызывался.
String? aiResponse,
// Per-field confidence scores (0100). Null = ещё не посчитан.
int? confidenceAmount,
int? confidenceAccount,
@@ -70,6 +70,9 @@ abstract interface class RawMessagesRepository {
Future<void> updateStatus(String id, RawMessageStatus status);
/// Сохранение сырого ответа AI (`completion.content`) — для журнала.
Future<void> setAiResponse(String id, String content);
/// Запись результатов парсинга (draft + per-field confidence).
Future<void> updateAfterParse({
required String id,
@@ -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),
@@ -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<void> _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<String?> _pickDevice(List<BondedBluetoothDevice> devices) {
final p = context.palette;
final l10n = context.l10n;
return showModalBottomSheet<String>(
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<Widget> children;
+27
View File
@@ -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');
}
+27
View File
@@ -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');
}
@@ -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);