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:
@@ -87,6 +87,11 @@ lib/
|
|||||||
# tickerText when extras body is empty or a "содержимое скрыто"
|
# tickerText when extras body is empty or a "содержимое скрыто"
|
||||||
# stub — VTB puts the real text ONLY in ticker, no second post),
|
# stub — VTB puts the real text ONLY in ticker, no second post),
|
||||||
# NotificationIngestWorker + ParsingWorker.
|
# 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
|
# Screens: inbox, rules_list, rule_editor, parsing_settings, ai_consent, parsing_log
|
||||||
profile/ # theme switcher screen
|
profile/ # theme switcher screen
|
||||||
shared/
|
shared/
|
||||||
|
|||||||
@@ -2,6 +2,17 @@
|
|||||||
<!-- В release-манифест Flutter НЕ добавляет INTERNET автоматически (в отличие
|
<!-- В release-манифест Flutter НЕ добавляет INTERNET автоматически (в отличие
|
||||||
от debug/profile) — без этой строки все сетевые вызовы падают. -->
|
от debug/profile) — без этой строки все сетевые вызовы падают. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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
|
<application
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
@@ -1,12 +1,211 @@
|
|||||||
package com.sanders.budget.new_budget
|
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 com.sanders.budget.new_budget.notifications.NotificationIngestPlugin
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
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() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
|
||||||
|
private var pendingAssociationResult: MethodChannel.Result? = null
|
||||||
|
private var pendingBondedResult: MethodChannel.Result? = null
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
NotificationIngestPlugin.register(flutterEngine, this)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,15 @@
|
|||||||
"parsingNotifAccessGranted": "Granted — notifications are being read",
|
"parsingNotifAccessGranted": "Granted — notifications are being read",
|
||||||
"parsingNotifAccessDenied": "Not granted — tap to open settings",
|
"parsingNotifAccessDenied": "Not granted — tap to open settings",
|
||||||
"parsingNotifAccessOpenSettings": "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",
|
"parsingDebugSectionTitle": "Debug",
|
||||||
"parsingDebugInjectTile": "Inject a test notification",
|
"parsingDebugInjectTile": "Inject a test notification",
|
||||||
|
|
||||||
@@ -269,6 +278,7 @@
|
|||||||
"parsingDetailRule": "Rule suggestion",
|
"parsingDetailRule": "Rule suggestion",
|
||||||
"parsingDetailMeta": "Meta",
|
"parsingDetailMeta": "Meta",
|
||||||
"parsingDetailDiagnostics": "Diagnostics",
|
"parsingDetailDiagnostics": "Diagnostics",
|
||||||
|
"parsingDetailAiResponse": "AI response",
|
||||||
"parsingDetailAttempt": "Attempt {count}/{max}",
|
"parsingDetailAttempt": "Attempt {count}/{max}",
|
||||||
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
|
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
|
||||||
"parsingDetailSource": "Source",
|
"parsingDetailSource": "Source",
|
||||||
|
|||||||
@@ -1070,6 +1070,54 @@ abstract class AppLocalizations {
|
|||||||
/// **'Открыть настройки'**
|
/// **'Открыть настройки'**
|
||||||
String get parsingNotifAccessOpenSettings;
|
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.
|
/// No description provided for @parsingDebugSectionTitle.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
@@ -1256,6 +1304,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Диагностика'**
|
/// **'Диагностика'**
|
||||||
String get parsingDetailDiagnostics;
|
String get parsingDetailDiagnostics;
|
||||||
|
|
||||||
|
/// No description provided for @parsingDetailAiResponse.
|
||||||
|
///
|
||||||
|
/// In ru, this message translates to:
|
||||||
|
/// **'Ответ ИИ'**
|
||||||
|
String get parsingDetailAiResponse;
|
||||||
|
|
||||||
/// No description provided for @parsingDetailAttempt.
|
/// No description provided for @parsingDetailAttempt.
|
||||||
///
|
///
|
||||||
/// In ru, this message translates to:
|
/// In ru, this message translates to:
|
||||||
|
|||||||
@@ -561,6 +561,35 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get parsingNotifAccessOpenSettings => 'Open settings';
|
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
|
@override
|
||||||
String get parsingDebugSectionTitle => 'Debug';
|
String get parsingDebugSectionTitle => 'Debug';
|
||||||
|
|
||||||
@@ -655,6 +684,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get parsingDetailDiagnostics => 'Diagnostics';
|
String get parsingDetailDiagnostics => 'Diagnostics';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingDetailAiResponse => 'AI response';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String parsingDetailAttempt(int count, int max) {
|
String parsingDetailAttempt(int count, int max) {
|
||||||
return 'Attempt $count/$max';
|
return 'Attempt $count/$max';
|
||||||
|
|||||||
@@ -573,6 +573,36 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get parsingNotifAccessOpenSettings => 'Открыть настройки';
|
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
|
@override
|
||||||
String get parsingDebugSectionTitle => 'Отладка';
|
String get parsingDebugSectionTitle => 'Отладка';
|
||||||
|
|
||||||
@@ -667,6 +697,9 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get parsingDetailDiagnostics => 'Диагностика';
|
String get parsingDetailDiagnostics => 'Диагностика';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get parsingDetailAiResponse => 'Ответ ИИ';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String parsingDetailAttempt(int count, int max) {
|
String parsingDetailAttempt(int count, int max) {
|
||||||
return 'Попытка $count/$max';
|
return 'Попытка $count/$max';
|
||||||
|
|||||||
@@ -235,6 +235,15 @@
|
|||||||
"parsingNotifAccessGranted": "Выдан — уведомления читаются",
|
"parsingNotifAccessGranted": "Выдан — уведомления читаются",
|
||||||
"parsingNotifAccessDenied": "Не выдан — нажмите, чтобы открыть настройки",
|
"parsingNotifAccessDenied": "Не выдан — нажмите, чтобы открыть настройки",
|
||||||
"parsingNotifAccessOpenSettings": "Открыть настройки",
|
"parsingNotifAccessOpenSettings": "Открыть настройки",
|
||||||
|
"parsingCompanionTitle": "Полный доступ к уведомлениям",
|
||||||
|
"parsingCompanionBound": "Часы привязаны — текст уведомлений приходит целиком",
|
||||||
|
"parsingCompanionNotBound": "Android скрывает текст банковских уведомлений. Привяжите часы, чтобы приложение стало доверенным.",
|
||||||
|
"parsingCompanionBind": "Привязать часы",
|
||||||
|
"parsingCompanionBindFailed": "Не удалось привязать: {error}",
|
||||||
|
"@parsingCompanionBindFailed": { "placeholders": { "error": { "type": "String" } } },
|
||||||
|
"parsingCompanionPickerTitle": "Выберите ваши часы",
|
||||||
|
"parsingCompanionPickerScan": "Другое устройство — системный поиск",
|
||||||
|
"parsingCompanionBtDenied": "Нет разрешения Bluetooth — выдайте его в настройках приложения",
|
||||||
"parsingDebugSectionTitle": "Отладка",
|
"parsingDebugSectionTitle": "Отладка",
|
||||||
"parsingDebugInjectTile": "Вставить тестовое уведомление",
|
"parsingDebugInjectTile": "Вставить тестовое уведомление",
|
||||||
|
|
||||||
@@ -269,6 +278,7 @@
|
|||||||
"parsingDetailRule": "Предложение правила",
|
"parsingDetailRule": "Предложение правила",
|
||||||
"parsingDetailMeta": "Мета",
|
"parsingDetailMeta": "Мета",
|
||||||
"parsingDetailDiagnostics": "Диагностика",
|
"parsingDetailDiagnostics": "Диагностика",
|
||||||
|
"parsingDetailAiResponse": "Ответ ИИ",
|
||||||
"parsingDetailAttempt": "Попытка {count}/{max}",
|
"parsingDetailAttempt": "Попытка {count}/{max}",
|
||||||
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
|
"@parsingDetailAttempt": { "placeholders": { "count": { "type": "int" }, "max": { "type": "int" } } },
|
||||||
"parsingDetailSource": "Источник",
|
"parsingDetailSource": "Источник",
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
AppDatabase.forTesting(super.executor);
|
AppDatabase.forTesting(super.executor);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 3;
|
int get schemaVersion => 4;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -77,9 +77,15 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
onUpgrade: (m, from, to) async {
|
onUpgrade: (m, from, to) async {
|
||||||
if (from < 2) await _migrateV1ToV2(m);
|
if (from < 2) await _migrateV1ToV2(m);
|
||||||
if (from < 3) await _migrateV2ToV3(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` исчезает, появляются
|
/// v2 → v3: единая модель правил — колонка `kind` исчезает, появляются
|
||||||
/// `is_ignore` и `auto_apply` (план «Правила парсинга: единая модель»):
|
/// `is_ignore` и `auto_apply` (план «Правила парсинга: единая модель»):
|
||||||
/// - kind='ignore' → is_ignore=1;
|
/// - 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 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
import '../../../core/providers/database_provider.dart';
|
import '../../../core/providers/database_provider.dart';
|
||||||
|
import '../data/native/companion_device_channel.dart';
|
||||||
import '../data/native/notification_listener_channel.dart';
|
import '../data/native/notification_listener_channel.dart';
|
||||||
import '../data/repositories/parse_rules_repository_impl.dart';
|
import '../data/repositories/parse_rules_repository_impl.dart';
|
||||||
import '../data/repositories/raw_messages_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 notificationListenerChannel(Ref ref) =>
|
||||||
NotificationListenerChannel();
|
NotificationListenerChannel();
|
||||||
|
|
||||||
|
/// Платформенный канал CompanionDeviceManager — привязка «часов» для полного
|
||||||
|
/// доступа к тексту уведомлений (Android).
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
CompanionDeviceChannel companionDeviceChannel(Ref ref) =>
|
||||||
|
CompanionDeviceChannel();
|
||||||
|
|
||||||
/// Список установленных запускаемых приложений (для экрана выбора источников).
|
/// Список установленных запускаемых приложений (для экрана выбора источников).
|
||||||
/// autoDispose: грузится при открытии пикера, освобождается после закрытия.
|
/// autoDispose: грузится при открытии пикера, освобождается после закрытия.
|
||||||
@riverpod
|
@riverpod
|
||||||
|
|||||||
@@ -188,6 +188,12 @@ class ParsingPipeline {
|
|||||||
.read(parsingSettingsControllerProvider.notifier)
|
.read(parsingSettingsControllerProvider.notifier)
|
||||||
.addTokenUsage(outcome.tokensUsed);
|
.addTokenUsage(outcome.tokensUsed);
|
||||||
}
|
}
|
||||||
|
// Сырой ответ модели — до ветвления, чтобы попасть в журнал при любом
|
||||||
|
// исходе (draft/partial/ignored).
|
||||||
|
final rawContent = outcome.rawContent;
|
||||||
|
if (rawContent != null && rawContent.isNotEmpty) {
|
||||||
|
await repo.setAiResponse(msg.id, rawContent);
|
||||||
|
}
|
||||||
switch (outcome.status) {
|
switch (outcome.status) {
|
||||||
case AiParseStatus.ignored:
|
case AiParseStatus.ignored:
|
||||||
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
await repo.updateStatus(msg.id, RawMessageStatus.ignored);
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ LIMIT ?4
|
|||||||
RawMessagesTableCompanion(status: Value(status)),
|
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-оценки).
|
/// Запись результатов парсинга (draft + confidence-оценки).
|
||||||
///
|
///
|
||||||
/// [lastParseError] пишется только если передан (диагностика
|
/// [lastParseError] пишется только если передан (диагностика
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class RawMessagesTable extends Table {
|
|||||||
/// режиме. Показывается в детальной панели «Журнала парсинга».
|
/// режиме. Показывается в детальной панели «Журнала парсинга».
|
||||||
TextColumn get diagnostics => text().nullable()();
|
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):
|
// Per-field confidence scores (0–100):
|
||||||
IntColumn get confidenceAmount => integer().nullable()();
|
IntColumn get confidenceAmount => integer().nullable()();
|
||||||
IntColumn get confidenceAccount => integer().nullable()();
|
IntColumn get confidenceAccount => integer().nullable()();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ extension RawMessageMapper on RawMessagesTableData {
|
|||||||
lastParseError: lastParseError,
|
lastParseError: lastParseError,
|
||||||
draftJson: draftJson,
|
draftJson: draftJson,
|
||||||
diagnostics: diagnostics,
|
diagnostics: diagnostics,
|
||||||
|
aiResponse: aiResponse,
|
||||||
confidenceAmount: confidenceAmount,
|
confidenceAmount: confidenceAmount,
|
||||||
confidenceAccount: confidenceAccount,
|
confidenceAccount: confidenceAccount,
|
||||||
confidenceType: confidenceType,
|
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 tokens = completion.totalTokens;
|
||||||
|
|
||||||
final json = extractJsonObject(completion.content);
|
final content = completion.content;
|
||||||
if (json == null) return AiParseOutcome.partial(tokens);
|
|
||||||
|
final json = extractJsonObject(content);
|
||||||
|
if (json == null) return AiParseOutcome.partial(tokens, rawContent: content);
|
||||||
|
|
||||||
final typeStr = (json['type'] as String?)?.toLowerCase();
|
final typeStr = (json['type'] as String?)?.toLowerCase();
|
||||||
final kind = _kindFrom(json['kind'] as String?);
|
final kind = _kindFrom(json['kind'] as String?);
|
||||||
if (typeStr == 'ignored' || kind == TxKind.balance) {
|
if (typeStr == 'ignored' || kind == TxKind.balance) {
|
||||||
return AiParseOutcome.ignored(tokens);
|
return AiParseOutcome.ignored(tokens, rawContent: content);
|
||||||
}
|
}
|
||||||
|
|
||||||
final type = _typeFrom(typeStr);
|
final type = _typeFrom(typeStr);
|
||||||
final amount = _amountMinor(json['amount']);
|
final amount = _amountMinor(json['amount']);
|
||||||
if (type == null || amount == null || amount <= 0) {
|
if (type == null || amount == null || amount <= 0) {
|
||||||
return AiParseOutcome.partial(tokens);
|
return AiParseOutcome.partial(tokens, rawContent: content);
|
||||||
}
|
}
|
||||||
|
|
||||||
final draft = ParseDraft(
|
final draft = ParseDraft(
|
||||||
@@ -60,7 +62,7 @@ class AiParser {
|
|||||||
categorySuggestion: _str(json['categorySuggestion']),
|
categorySuggestion: _str(json['categorySuggestion']),
|
||||||
source: ParseSource.ai,
|
source: ParseSource.ai,
|
||||||
);
|
);
|
||||||
return AiParseOutcome.draft(draft, tokens);
|
return AiParseOutcome.draft(draft, tokens, rawContent: content);
|
||||||
}
|
}
|
||||||
|
|
||||||
TransactionType? _typeFrom(String? s) => switch (s) {
|
TransactionType? _typeFrom(String? s) => switch (s) {
|
||||||
@@ -107,18 +109,27 @@ class AiParser {
|
|||||||
enum AiParseStatus { draft, partial, ignored }
|
enum AiParseStatus { draft, partial, ignored }
|
||||||
|
|
||||||
class AiParseOutcome {
|
class AiParseOutcome {
|
||||||
const AiParseOutcome._(this.status, this.draft, this.tokensUsed);
|
const AiParseOutcome._(this.status, this.draft, this.tokensUsed,
|
||||||
|
{this.rawContent});
|
||||||
|
|
||||||
final AiParseStatus status;
|
final AiParseStatus status;
|
||||||
final ParseDraft? draft;
|
final ParseDraft? draft;
|
||||||
final int tokensUsed;
|
final int tokensUsed;
|
||||||
|
|
||||||
factory AiParseOutcome.draft(ParseDraft draft, int tokens) =>
|
/// Сырой `completion.content` модели (до извлечения JSON) — сохраняется
|
||||||
AiParseOutcome._(AiParseStatus.draft, draft, tokens);
|
/// в `raw_messages.aiResponse` для «Журнала парсинга».
|
||||||
|
final String? rawContent;
|
||||||
|
|
||||||
factory AiParseOutcome.partial(int tokens) =>
|
factory AiParseOutcome.draft(ParseDraft draft, int tokens,
|
||||||
AiParseOutcome._(AiParseStatus.partial, null, tokens);
|
{String? rawContent}) =>
|
||||||
|
AiParseOutcome._(AiParseStatus.draft, draft, tokens,
|
||||||
|
rawContent: rawContent);
|
||||||
|
|
||||||
factory AiParseOutcome.ignored(int tokens) =>
|
factory AiParseOutcome.partial(int tokens, {String? rawContent}) =>
|
||||||
AiParseOutcome._(AiParseStatus.ignored, null, tokens);
|
AiParseOutcome._(AiParseStatus.partial, null, tokens,
|
||||||
|
rawContent: rawContent);
|
||||||
|
|
||||||
|
factory AiParseOutcome.ignored(int tokens, {String? rawContent}) =>
|
||||||
|
AiParseOutcome._(AiParseStatus.ignored, null, tokens,
|
||||||
|
rawContent: rawContent);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -124,6 +124,10 @@ class RawMessagesRepositoryImpl implements RawMessagesRepository {
|
|||||||
Future<void> updateStatus(String id, RawMessageStatus status) =>
|
Future<void> updateStatus(String id, RawMessageStatus status) =>
|
||||||
_dao.updateStatus(id, status);
|
_dao.updateStatus(id, status);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setAiResponse(String id, String content) =>
|
||||||
|
_dao.setAiResponse(id, content);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> updateAfterParse({
|
Future<void> updateAfterParse({
|
||||||
required String id,
|
required String id,
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ abstract class RawMessage with _$RawMessage {
|
|||||||
/// Null в обычном режиме.
|
/// Null в обычном режиме.
|
||||||
String? diagnostics,
|
String? diagnostics,
|
||||||
|
|
||||||
|
/// Сырой `completion.content` последнего AI-разбора (до извлечения JSON).
|
||||||
|
/// Null, пока AI не вызывался.
|
||||||
|
String? aiResponse,
|
||||||
|
|
||||||
// Per-field confidence scores (0–100). Null = ещё не посчитан.
|
// Per-field confidence scores (0–100). Null = ещё не посчитан.
|
||||||
int? confidenceAmount,
|
int? confidenceAmount,
|
||||||
int? confidenceAccount,
|
int? confidenceAccount,
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ abstract interface class RawMessagesRepository {
|
|||||||
|
|
||||||
Future<void> updateStatus(String id, RawMessageStatus status);
|
Future<void> updateStatus(String id, RawMessageStatus status);
|
||||||
|
|
||||||
|
/// Сохранение сырого ответа AI (`completion.content`) — для журнала.
|
||||||
|
Future<void> setAiResponse(String id, String content);
|
||||||
|
|
||||||
/// Запись результатов парсинга (draft + per-field confidence).
|
/// Запись результатов парсинга (draft + per-field confidence).
|
||||||
Future<void> updateAfterParse({
|
Future<void> updateAfterParse({
|
||||||
required String id,
|
required String id,
|
||||||
|
|||||||
@@ -434,6 +434,21 @@ class _DetailPanel extends StatelessWidget {
|
|||||||
_kv(context, l10n.parsingDetailRuleCategory,
|
_kv(context, l10n.parsingDetailRuleCategory,
|
||||||
suggestion.categoryName),
|
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, [
|
_section(context, l10n.parsingDetailMeta, [
|
||||||
_kv(context, l10n.parsingDetailTransactionId, message.transactionId,
|
_kv(context, l10n.parsingDetailTransactionId, message.transactionId,
|
||||||
mono: true),
|
mono: true),
|
||||||
|
|||||||
+184
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show PlatformException;
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/router/app_routes.dart';
|
||||||
import '../../../../app/theme/app_colors.dart';
|
import '../../../../app/theme/app_colors.dart';
|
||||||
import '../../../user/application/active_user_controller.dart';
|
import '../../../user/application/active_user_controller.dart';
|
||||||
|
import '../../application/companion_access_controller.dart';
|
||||||
import '../../application/notification_access_controller.dart';
|
import '../../application/notification_access_controller.dart';
|
||||||
|
import '../../data/native/companion_device_channel.dart';
|
||||||
import '../../application/parsing_settings_controller.dart';
|
import '../../application/parsing_settings_controller.dart';
|
||||||
import '../../application/rules_controller.dart';
|
import '../../application/rules_controller.dart';
|
||||||
import '../../domain/entities/parse_rule.dart';
|
import '../../domain/entities/parse_rule.dart';
|
||||||
@@ -57,6 +60,8 @@ class ParsingSettingsScreen extends ConsumerWidget {
|
|||||||
if (defaultTargetPlatform == TargetPlatform.android) ...[
|
if (defaultTargetPlatform == TargetPlatform.android) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const _NotificationAccessCard(),
|
const _NotificationAccessCard(),
|
||||||
|
// Сама рисует свой верхний отступ, когда видима.
|
||||||
|
const _CompanionAccessCard(),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_Card(
|
_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 {
|
class _Card extends StatelessWidget {
|
||||||
const _Card({required this.children});
|
const _Card({required this.children});
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
|
|||||||
@@ -90,6 +90,33 @@ CREATE TABLE account_bindings (
|
|||||||
'(id, user_id, phone, account_id) VALUES '
|
'(id, user_id, phone, account_id) VALUES '
|
||||||
"('b6', '$_userId', '+79990001122', 'acc-4')");
|
"('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');
|
raw.execute('PRAGMA user_version = 1');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,33 @@ CREATE TABLE parse_rules (
|
|||||||
"('r-ignore', '$_userId', '$_sber', 'ignore', 'regex', "
|
"('r-ignore', '$_userId', '$_sber', 'ignore', 'regex', "
|
||||||
"'заказ \\d+', 0, 0, NULL, NULL, NULL, NULL, 0)");
|
"'заказ \\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');
|
raw.execute('PRAGMA user_version = 2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,10 @@ void main() {
|
|||||||
expect(msg.status, RawMessageStatus.inbox,
|
expect(msg.status, RawMessageStatus.inbox,
|
||||||
reason: 'правило bankA не видно в bankB → нет auto-apply');
|
reason: 'правило bankA не видно в bankB → нет auto-apply');
|
||||||
|
|
||||||
|
// Сырой ответ модели сохранён для «Журнала парсинга».
|
||||||
|
expect(msg.aiResponse, isNotNull);
|
||||||
|
expect(msg.aiResponse, contains('LENTA'));
|
||||||
|
|
||||||
// Мерчант «незнакомый» в скоупе bankB → pipeline предлагает создать правило.
|
// Мерчант «незнакомый» в скоупе bankB → pipeline предлагает создать правило.
|
||||||
final bundle = decodeDraftBundle(msg.draftJson);
|
final bundle = decodeDraftBundle(msg.draftJson);
|
||||||
expect(bundle, isNotNull);
|
expect(bundle, isNotNull);
|
||||||
|
|||||||
Reference in New Issue
Block a user