delete CloudKit and added BalanceFlutter
Android CI / Build and Test (push) Failing after 21m18s
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

This commit is contained in:
wt committed 2026-10-28 15:37:32 +07:00
1 parent a715eca947
commit 2c278044bb
214 files changed
+13411 -2290

No files matched your search

+796
View File
@@ -0,0 +1,796 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../domain/finance.dart';
import 'storage.dart';
import 'runtime_lock.dart';
import 'sync_api.dart';
class AuthSession {
AuthSession(this.data) {
if (userId.isEmpty ||
email.isEmpty ||
accessToken.isEmpty ||
refreshToken.isEmpty ||
expiresAt.millisecondsSinceEpoch <= 0) {
throw const FormatException('Invalid saved session');
}
}
final Map<String, dynamic> data;
String get userId => (data['user'] as Map)['id'] as String;
String get email => (data['user'] as Map)['email'] as String;
String get accessToken => data['accessToken'] as String;
String get refreshToken => data['refreshToken'] as String;
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
}
/// All durable mutations and account transitions share one queue. A pull page
/// writes records, tombstones, dirty changes and cursor in one database commit.
class AppStore extends ChangeNotifier with WidgetsBindingObserver {
AppStore({
SnapshotStorage? storage,
FlutterSecureStorage? secureStorage,
SyncApi Function(String serverUrl)? apiFactory,
this.background = false,
}) : _storage = storage,
_injectedStorage = storage != null,
_apiFactory = apiFactory,
_secureStorage = secureStorage ?? const FlutterSecureStorage();
SnapshotStorage? _storage;
final bool background;
final bool _injectedStorage;
final RuntimeLock _runtimeLock = RuntimeLock();
AuthSession? _volatileSession;
String? _volatileRevision;
String? _volatileServer;
final SyncApi Function(String serverUrl)? _apiFactory;
final FlutterSecureStorage _secureStorage;
SharedPreferences? _preferences;
Future<void> _tail = Future<void>.value();
Timer? _timer;
bool _disposed = false;
bool ready = false;
bool syncing = false;
String? error;
String? syncMessage;
DateTime? lastSyncAt;
String serverUrl = '';
String currency = 'RUB';
String theme = 'system';
AuthSession? session;
String? sessionPersistenceWarning;
String get email => session?.email ?? '';
String _deviceId = '';
String _scope = '';
int _cursor = 0;
final Map<String, TransactionRecord> _transactions = {};
final Map<String, CategoryRecord> _categories = {};
final Map<String, BudgetRecord> _budgets = {};
final Map<String, Map<String, dynamic>> _tombstones = {};
final Map<String, Map<String, dynamic>> _dirty = {};
List<TransactionRecord> get transactions =>
_transactions.values.toList()..sort((a, b) => b.date.compareTo(a.date));
List<CategoryRecord> get categories =>
_categories.values.toList()..sort((a, b) => a.name.compareTo(b.name));
List<BudgetRecord> get budgets =>
_budgets.values.toList()
..sort((a, b) => b.monthStart.compareTo(a.monthStart));
int get balanceMinor => _transactions.values.fold(
0,
(sum, t) =>
sum + (t.kindRawValue == 'income' ? t.amountMinor : -t.amountMinor),
);
int get pendingChangeCount => _dirty.length;
int get cursor => _cursor;
static String newId() => const Uuid().v4();
Future<T> _exclusive<T>(Future<T> Function() body) {
final result = _tail.then((_) async {
if (_disposed) throw StateError('AppStore is closed');
if (!_injectedStorage) await _runtimeLock.acquire();
try {
if (ready) await _reloadSharedState();
return await body();
} finally {
if (!_injectedStorage) await _runtimeLock.release();
}
});
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
void _notify() {
if (!_disposed) notifyListeners();
}
void clearError() {
error = null;
_notify();
}
Future<void> initialize() => _exclusive(() async {
if (ready) return;
_preferences = await SharedPreferences.getInstance();
await _preferences!.reload();
serverUrl = _preferences!.getString('serverUrl') ?? serverUrl;
currency = _preferences!.getString('currency') ?? currency;
theme = _preferences!.getString('theme') ?? theme;
_deviceId = _preferences!.getString('deviceId') ?? newId();
await _preferences!.setString('deviceId', _deviceId);
_storage ??= await LedgerStorage.open();
await _restoreSession();
await _loadScope();
if (!background) {
WidgetsBinding.instance.addObserver(this);
_timer = Timer.periodic(const Duration(minutes: 2), (_) {
_backgroundSync();
});
}
ready = true;
_notify();
if (!background) _backgroundSync();
});
Future<void> _reloadSharedState() async {
await _preferences!.reload();
final nextServer = _preferences!.getString('serverUrl') ?? '';
if (nextServer != serverUrl) {
_apiInstance?.close();
_apiInstance = null;
serverUrl = nextServer;
}
currency = _preferences!.getString('currency') ?? 'RUB';
theme = _preferences!.getString('theme') ?? 'system';
_deviceId = _preferences!.getString('deviceId') ?? _deviceId;
await _restoreSession();
await _loadScope();
_notify();
}
Future<void> _restoreSession() async {
session = null;
final revision = _preferences!.getString('sessionRevision.$serverUrl');
if (_volatileServer == serverUrl &&
_volatileRevision == revision &&
_volatileSession != null) {
session = _volatileSession;
return;
}
_volatileSession = null;
_volatileRevision = null;
_volatileServer = null;
String? saved;
try {
if (!(_preferences!.getBool('sessionDisabled.$serverUrl') ?? false)) {
saved = await _secureStorage.read(key: _credentialKey);
}
} catch (_) {
_sessionStorageUnavailable();
}
if (saved != null) {
try {
session = AuthSession(
Map<String, dynamic>.from(jsonDecode(saved) as Map),
);
} catch (_) {
await _deleteCredentials();
}
}
}
void _sessionStorageUnavailable() {
sessionPersistenceWarning =
'Защищённое хранилище недоступно. Вход действует до закрытия приложения.';
syncMessage = sessionPersistenceWarning;
}
Future<void> _saveCredentials(Map<String, dynamic> value) async {
try {
await _secureStorage.write(key: _credentialKey, value: jsonEncode(value));
await _preferences!.setBool('sessionDisabled.$serverUrl', false);
await _preferences!.setString('sessionRevision.$serverUrl', newId());
_volatileSession = null;
_volatileRevision = null;
_volatileServer = null;
} catch (_) {
await _preferences!.setBool('sessionDisabled.$serverUrl', true);
_volatileRevision = newId();
await _preferences!.setString(
'sessionRevision.$serverUrl',
_volatileRevision!,
);
_volatileSession = AuthSession(value);
_volatileServer = serverUrl;
_sessionStorageUnavailable();
}
}
Future<void> _deleteCredentials() async {
// A failed secure delete must not restore a signed-out account next launch.
await _preferences!.setBool('sessionDisabled.$serverUrl', true);
await _preferences!.setString('sessionRevision.$serverUrl', newId());
_volatileSession = null;
_volatileRevision = null;
_volatileServer = null;
try {
await _secureStorage.delete(key: _credentialKey);
} catch (_) {
_sessionStorageUnavailable();
}
}
String get _credentialKey =>
'balance.session.${Uri.encodeComponent(serverUrl)}';
String _scopeFor(String? userId) => '$serverUrl|${userId ?? 'guest'}';
SyncApi? _apiInstance;
SyncApi get _api => _apiInstance ??=
_apiFactory?.call(serverUrl) ??
SyncApi(
serverUrl: serverUrl,
allowInsecureHttp: [
'localhost',
'127.0.0.1',
'::1',
'10.0.2.2',
].contains(Uri.parse(serverUrl).host),
);
List<CategoryRecord> categoriesForKind(Object kind) {
final raw = kind is TransactionKind ? kind.name : kind.toString();
final combined = <String, CategoryRecord>{
for (final category in BuiltInCategories.forKind(raw))
category.name: category,
for (final category in categories.where((c) => c.kindRawValue == raw))
category.name: category,
};
return combined.values.toList();
}
Future<void> _loadScope() async {
_scope = _scopeFor(session?.userId);
final raw = await _storage!.read(_scope);
_transactions.clear();
_categories.clear();
_budgets.clear();
_dirty.clear();
_tombstones.clear();
_cursor = 0;
lastSyncAt = null;
if (raw == null) return;
for (final item in (raw['transactions'] as List? ?? [])) {
final record = TransactionRecord.fromJson(
Map<String, dynamic>.from(item as Map),
);
_transactions[record.id] = record;
}
for (final item in (raw['categories'] as List? ?? [])) {
final record = CategoryRecord.fromJson(
Map<String, dynamic>.from(item as Map),
);
_categories[record.id] = record;
}
for (final item in (raw['budgets'] as List? ?? [])) {
final record = BudgetRecord.fromJson(
Map<String, dynamic>.from(item as Map),
);
_budgets[record.id] = record;
}
for (final item in (raw['dirty'] as List? ?? [])) {
final change = Map<String, dynamic>.from(item as Map);
_dirty[_key(change)] = change;
}
for (final item in (raw['tombstones'] as List? ?? [])) {
final change = Map<String, dynamic>.from(item as Map);
_tombstones[_key(change)] = change;
}
_cursor = (raw['cursor'] as num?)?.toInt() ?? 0;
lastSyncAt = DateTime.tryParse(raw['lastSyncAt'] as String? ?? '');
}
Map<String, dynamic> _snapshot() => {
'transactions': _transactions.values.map((e) => e.toJson()).toList(),
'categories': _categories.values.map((e) => e.toJson()).toList(),
'budgets': _budgets.values.map((e) => e.toJson()).toList(),
'dirty': _dirty.values.toList(),
'tombstones': _tombstones.values.toList(),
'cursor': _cursor,
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
};
Future<void> _persist() => _storage!.write(_scope, _snapshot());
String _key(Map<String, dynamic> change) =>
'${change['entity']}:${change['id']}';
Map<String, dynamic> _change(
String entity,
String id,
DateTime time,
Map<String, dynamic>? payload,
) => {
'entity': entity,
'id': id,
'updatedAt': time.toUtc().toIso8601String(),
'deleted': payload == null,
'payload': ?payload,
};
DateTime _now() {
// Strictly advance local timestamps even for successive edits in one tick.
var now = DateTime.now().toUtc();
for (final change in _dirty.values) {
final previous = DateTime.parse(change['updatedAt'] as String);
if (!now.isAfter(previous)) {
now = previous.add(const Duration(microseconds: 1));
}
}
return now;
}
void _queueChange(Map<String, dynamic> change) {
final key = _key(change);
_dirty[key] = change;
if (change['deleted'] == true) {
_tombstones[key] = change;
} else {
_tombstones.remove(key);
}
}
Future<void> _edited() async {
try {
await _persist();
} catch (_) {
await _loadScope();
rethrow;
}
_notify();
}
Future<void> saveTransaction(TransactionRecord record) =>
_exclusive(() async {
if (record.amountMinor <= 0) {
throw ArgumentError('Сумма должна быть больше нуля');
}
final saved = record.copyWith(updatedAt: _now());
_transactions[saved.id] = saved;
_queueChange(
_change('transaction', saved.id, saved.updatedAt, saved.payload()),
);
await _edited();
});
Future<void> deleteTransaction(String id) => _delete('transaction', id);
Future<void> deleteBudget(String id) => _delete('budget', id);
Future<void> deleteCategory(String id) => _delete('category', id);
Future<void> _delete(String entity, String id) => _exclusive(() async {
if (entity == 'transaction') _transactions.remove(id);
if (entity == 'category') {
final category = _categories.remove(id);
if (category != null && category.kindRawValue == 'expense') {
for (final budget
in _budgets.values
.where((b) => b.categoryName == category.name)
.toList()) {
_budgets.remove(budget.id);
_queueChange(_change('budget', budget.id, _now(), null));
}
}
}
if (entity == 'budget') _budgets.remove(id);
_queueChange(_change(entity, id, _now(), null));
await _edited();
});
Future<void> saveCategory(CategoryRecord record) => _exclusive(() async {
if (record.name.trim().isEmpty) {
throw ArgumentError('Введите название категории');
}
final saved = record.copyWith(name: record.name.trim(), updatedAt: _now());
if (_categories.values.any(
(c) =>
c.id != saved.id &&
c.name == saved.name &&
c.kindRawValue == saved.kindRawValue,
)) {
throw ArgumentError('Такая категория уже существует');
}
final previous = _categories[saved.id];
_categories[saved.id] = saved;
_queueChange(
_change('category', saved.id, saved.updatedAt, saved.payload()),
);
if (previous != null) {
for (final tx in _transactions.values.toList()) {
if (tx.categoryName != previous.name ||
tx.kindRawValue != previous.kindRawValue) {
continue;
}
final changed = tx.copyWith(
categoryName: saved.name,
categoryIcon: saved.icon,
categoryEmoji: saved.emoji,
categoryColorName: saved.colorName,
updatedAt: saved.updatedAt,
);
_transactions[tx.id] = changed;
_queueChange(
_change('transaction', tx.id, changed.updatedAt, changed.payload()),
);
}
for (final budget in _budgets.values.toList()) {
if (budget.categoryName != previous.name) continue;
final changed = budget.copyWith(
categoryName: saved.name,
categoryIcon: saved.icon,
categoryEmoji: saved.emoji,
updatedAt: saved.updatedAt,
);
_budgets[budget.id] = changed;
_queueChange(
_change('budget', budget.id, changed.updatedAt, changed.payload()),
);
}
}
await _edited();
});
Future<void> saveBudget(BudgetRecord record) => _exclusive(() async {
if (record.limitMinor <= 0) {
throw ArgumentError('Лимит должен быть больше нуля');
}
final localMonth = record.monthStart.toLocal();
final month = DateTime(localMonth.year, localMonth.month);
if (_budgets.values.any(
(b) =>
b.id != record.id &&
b.categoryName == record.categoryName &&
b.monthStart.toLocal().year == month.year &&
b.monthStart.toLocal().month == month.month,
)) {
throw ArgumentError('Бюджет для этой категории уже существует');
}
final saved = record.copyWith(monthStart: month, updatedAt: _now());
_budgets[saved.id] = saved;
_queueChange(_change('budget', saved.id, saved.updatedAt, saved.payload()));
await _edited();
});
Future<void> adjustBalance(int targetMinor, {String note = ''}) =>
_exclusive(() async {
final delta = targetMinor - balanceMinor;
if (delta == 0) return;
final now = _now();
final record = TransactionRecord(
id: newId(),
amountMinor: delta.abs(),
date: now,
categoryName: 'Корректировка',
updatedAt: now,
note: note.isEmpty ? 'Ручная корректировка баланса' : note,
isBalanceAdjustment: true,
kindRawValue: delta > 0 ? 'income' : 'expense',
);
_transactions[record.id] = record;
_queueChange(_change('transaction', record.id, now, record.payload()));
await _edited();
});
Future<void> setCurrency(String value) => _exclusive(() async {
currency = value;
await _preferences!.setString('currency', value);
_notify();
});
Future<void> setTheme(String value) => _exclusive(() async {
theme = value;
await _preferences!.setString('theme', value);
_notify();
});
Future<void> setServerUrl(String value) => _exclusive(() async {
final normalized = value.trim().replaceAll(RegExp(r'/+$'), '');
final uri = Uri.tryParse(normalized);
if (uri == null ||
!uri.hasAuthority ||
!['http', 'https'].contains(uri.scheme)) {
throw ArgumentError('Укажите полный адрес сервера');
}
// Validate the transport before clearing an existing account or changing its scope.
final validation = SyncApi(
serverUrl: normalized,
allowInsecureHttp: const [
'localhost',
'127.0.0.1',
'::1',
'10.0.2.2',
].contains(uri.host),
);
validation.close();
if (normalized == serverUrl) return;
final unconfiguredGuest = serverUrl.isEmpty && session == null
? _snapshot()
: null;
await _deleteCredentials();
session = null;
_apiInstance?.close();
_apiInstance = null;
serverUrl = normalized;
await _preferences!.setString('serverUrl', normalized);
// Credentials are deliberately not restored when switching servers.
await _deleteCredentials();
if (unconfiguredGuest != null &&
await _storage!.read(_scopeFor(null)) == null) {
await _storage!.write(_scopeFor(null), unconfiguredGuest);
}
await _loadScope();
error = null;
syncMessage = null;
_notify();
});
Future<void> signIn(String email, String password) =>
_authenticate(email, password, false);
Future<void> register(String email, String password) =>
_authenticate(email, password, true);
Future<void> _authenticate(
String email,
String password,
bool register,
) => _exclusive(() async {
final response = register
? await _api.register(email, password)
: await _api.login(email, password);
final nextSession = AuthSession(response);
final nextScope = _scopeFor(nextSession.userId);
final exists = await _storage!.read(nextScope);
final marker = 'importedGuest.$serverUrl';
final guest =
session == null &&
exists == null &&
!(_preferences!.getBool(marker) ?? false)
? _snapshot()
: null;
session = nextSession;
await _saveCredentials(response);
await _loadScope();
if (guest != null) {
await _storage!.write(_scope, guest);
await _loadScope();
_cursor = 0;
// Imported rows may have been synced under another prior guest snapshot.
for (final t in _transactions.values) {
_queueChange(_change('transaction', t.id, t.updatedAt, t.payload()));
}
for (final c in _categories.values) {
_queueChange(_change('category', c.id, c.updatedAt, c.payload()));
}
for (final b in _budgets.values) {
_queueChange(_change('budget', b.id, b.updatedAt, b.payload()));
}
await _persist();
await _preferences!.setBool(marker, true);
}
error = null;
_notify();
await _syncLocked();
});
Future<void> signOut() => _exclusive(() async {
final old = session;
// Local logout succeeds even if the network is unavailable.
session = null;
await _deleteCredentials();
await _loadScope();
error = null;
syncMessage = null;
_notify();
if (old != null) {
try {
await _api.logout(old.refreshToken);
} catch (_) {}
}
});
Future<String> _accessToken({bool force = false}) async {
final current = session;
if (current == null) throw StateError('Войдите в аккаунт');
if (!force &&
current.expiresAt.isAfter(
DateTime.now().add(const Duration(minutes: 1)),
)) {
return current.accessToken;
}
final refreshed = await _api.refresh(current.refreshToken);
session = AuthSession(refreshed);
await _saveCredentials(refreshed);
return session!.accessToken;
}
Future<void> sync() => _exclusive(_syncLocked);
Future<void> resetAndSync() => _exclusive(() async {
_cursor = 0;
for (final t in _transactions.values) {
_queueChange(_change('transaction', t.id, t.updatedAt, t.payload()));
}
for (final c in _categories.values) {
_queueChange(_change('category', c.id, c.updatedAt, c.payload()));
}
for (final b in _budgets.values) {
_queueChange(_change('budget', b.id, b.updatedAt, b.payload()));
}
for (final tombstone in _tombstones.values.toList()) {
_queueChange(tombstone);
}
await _persist();
await _syncLocked();
});
Future<void> _syncLocked() async {
if (session == null || syncing) return;
syncing = true;
error = null;
syncMessage = 'Синхронизация…';
_notify();
final sent = Map<String, Map<String, dynamic>>.from(_dirty);
try {
Future<int> run() => _api.synchronize(
accessToken: _accessToken,
cursor: _cursor,
deviceId: _deviceId,
changes: sent.values.toList(),
applyPage: _applyPage,
);
try {
await run();
} on SyncApiException catch (e) {
if (e.statusCode != 401) rethrow;
await _accessToken(force: true);
await run();
}
// Only acknowledge precisely the pushed revision, never a newer local edit.
for (final entry in sent.entries) {
if (_dirty[entry.key]?['updatedAt'] == entry.value['updatedAt']) {
_dirty.remove(entry.key);
}
}
lastSyncAt = DateTime.now();
await _persist();
syncMessage =
sessionPersistenceWarning ??
(_dirty.isEmpty
? 'Синхронизировано'
: 'Есть изменения для следующей синхронизации');
} catch (e) {
error = e is SyncApiException
? e.message
: 'Не удалось синхронизировать: $e';
syncMessage = 'Изменения сохранены на устройстве';
rethrow;
} finally {
syncing = false;
_notify();
}
}
Future<void> _applyPage(
List<Map<String, dynamic>> changes,
int cursor,
) async {
try {
for (final change in changes) {
_merge(change);
}
_cursor = cursor;
await _persist();
} catch (_) {
// Disk failure must not advance the in-memory cursor past an uncommitted page.
await _loadScope();
rethrow;
}
}
void _merge(Map<String, dynamic> change) {
final key = _key(change);
final entity = change['entity'] as String;
final id = change['id'] as String;
final incoming = DateTime.parse(change['updatedAt'] as String);
final pending = _dirty[key];
// Preserve the user's pending deletion. A newer server edit means its LWW
// delete needs a newer revision, which remains dirty for the next push.
if (pending?['deleted'] == true && change['deleted'] != true) {
if (!DateTime.parse(pending!['updatedAt'] as String).isAfter(incoming)) {
_queueChange(
_change(
entity,
id,
incoming.add(const Duration(microseconds: 1)),
null,
),
);
}
return;
}
if (pending != null &&
!incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) {
return;
}
final tombstone = _tombstones[key];
if (tombstone != null &&
!incoming.isAfter(DateTime.parse(tombstone['updatedAt'] as String))) {
return;
}
DateTime? local;
if (entity == 'transaction') local = _transactions[id]?.updatedAt;
if (entity == 'category') local = _categories[id]?.updatedAt;
if (entity == 'budget') local = _budgets[id]?.updatedAt;
if (local != null && incoming.isBefore(local)) return;
if (change['deleted'] == true) {
if (entity == 'transaction') _transactions.remove(id);
if (entity == 'category') _categories.remove(id);
if (entity == 'budget') _budgets.remove(id);
_tombstones[key] = Map<String, dynamic>.from(change);
} else {
final payload = Map<String, dynamic>.from(change['payload'] as Map);
if (entity == 'transaction') {
_transactions[id] = TransactionRecord.fromPayload(
id: id,
updatedAt: incoming,
payload: payload,
);
}
if (entity == 'category') {
_categories[id] = CategoryRecord.fromPayload(
id: id,
updatedAt: incoming,
payload: payload,
);
}
if (entity == 'budget') {
_budgets[id] = BudgetRecord.fromPayload(
id: id,
updatedAt: incoming,
payload: payload,
);
}
_tombstones.remove(key);
}
if (pending != null &&
incoming.isAfter(DateTime.parse(pending['updatedAt'] as String))) {
_dirty.remove(key);
}
}
void _backgroundSync() {
if (ready && session != null) unawaited(sync().catchError((Object _) {}));
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
_backgroundSync();
}
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.hidden) {
_timer?.cancel();
_timer = null;
} else if (state == AppLifecycleState.resumed) {
_timer ??= Timer.periodic(const Duration(minutes: 2), (_) {
_backgroundSync();
});
}
}
/// Wait for all serialized work before a headless engine exits.
Future<void> close() async {
await _tail;
_apiInstance?.close();
_apiInstance = null;
await _storage?.close();
}
@override
void dispose() {
_disposed = true;
_timer?.cancel();
if (!background) WidgetsBinding.instance.removeObserver(this);
unawaited(close());
super.dispose();
}
}
@@ -0,0 +1,51 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:workmanager/workmanager.dart';
import 'app_store.dart';
const _periodicSyncName = 'balance-periodic-sync';
const _syncTask = 'balance.finance.sync';
@pragma('vm:entry-point')
void balanceBackgroundDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
Workmanager().executeTask((task, inputData) async {
if (task != _syncTask) return true;
final store = AppStore(background: true);
var success = false;
try {
await store.initialize();
await store.sync();
success = true;
} catch (_) {
// WorkManager retries with backoff; details/tokens are never logged.
success = false;
} finally {
try {
await store.close();
} catch (_) {
success = false;
}
store.dispose();
}
return success;
});
}
/// Android OS scheduling survives UI engine shutdown. Other platforms retain
/// their existing foreground startup/resume/two-minute synchronization.
Future<void> configureBackgroundSync() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
await Workmanager().initialize(balanceBackgroundDispatcher);
await Workmanager().registerPeriodicTask(
_periodicSyncName,
_syncTask,
frequency: const Duration(minutes: 15),
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingPeriodicWorkPolicy.update,
);
}
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'runtime_lock_stub.dart'
if (dart.library.js_interop) 'runtime_lock_web.dart'
as browser;
/// Native Android lock shared by the UI engine and WorkManager headless engines.
/// Callers must hold it over reloading preferences/session, ledger IO and sync.
class RuntimeLock {
static const _channel = MethodChannel('balance/runtime');
final _browserLock = browser.BrowserRuntimeLock();
String? _token;
bool get _android =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
Future<void> acquire() async {
if (kIsWeb) return _browserLock.acquire();
if (!_android) return;
if (_token != null) throw StateError('Ledger lock is already held');
final token = await _channel.invokeMethod<String>('acquireLock');
if (token == null || token.isEmpty) {
throw StateError('Native ledger lock returned no ownership token');
}
_token = token;
}
Future<void> release() async {
if (kIsWeb) return _browserLock.release();
if (!_android) return;
final token = _token;
if (token == null) throw StateError('Ledger lock is not held');
await _channel.invokeMethod<void>('releaseLock', {'token': token});
_token = null;
}
}
@@ -0,0 +1,5 @@
/// Non-web storage does not use browser-origin coordination.
class BrowserRuntimeLock {
Future<void> acquire() async {}
Future<void> release() async {}
}
@@ -0,0 +1,96 @@
import 'dart:async';
import 'dart:js_interop';
@JS('isSecureContext')
external bool get _secureContext;
@JS('navigator.locks')
external JSObject? get _navigatorLocks;
extension type _LockManager(JSObject _) implements JSObject {
external JSPromise<JSAny?> request(JSString name, JSFunction callback);
}
/// Holds the origin-wide Web Lock until release, including IndexedDB commits
/// and rotated refresh-token persistence. Browser teardown releases the lock.
class BrowserRuntimeLock {
bool _waiting = false;
Completer<JSAny?>? _release;
Future<JSAny?>? _request;
Future<void> acquire() async {
if (_waiting || _release != null) {
throw StateError('Browser ledger lock is already held or pending');
}
if (!_secureContext) {
throw StateError(
'Для безопасного хранения данных в нескольких вкладках требуется HTTPS '
'(или localhost). Откройте приложение по защищённому адресу.',
);
}
final manager = _navigatorLocks;
if (manager == null) {
throw StateError(
'Браузер не поддерживает Web Locks API. Обновите браузер: '
'без блокировки вкладок безопасная синхронизация недоступна.',
);
}
final ready = Completer<void>();
final release = Completer<JSAny?>();
_waiting = true;
try {
final request = _LockManager(manager)
.request(
'balance-ledger-v1'.toJS,
((JSAny? lock) {
_waiting = false;
_release = release;
ready.complete();
return release.future.toJS;
}).toJS,
)
.toDart;
_request = request;
// A rejected request must wake an acquiring caller, not leave it waiting
// forever. Keep a rejection handler attached even before release awaits it.
unawaited(
request.then<void>(
(_) {
if (!ready.isCompleted) {
ready.completeError(
StateError('Browser ledger lock was not granted'),
);
}
},
onError: (Object error, StackTrace stack) {
if (!ready.isCompleted) {
ready.completeError(
StateError('Не удалось получить блокировку данных браузера'),
stack,
);
}
},
),
);
await ready.future;
} catch (_) {
_waiting = false;
_release = null;
_request = null;
rethrow;
}
}
Future<void> release() async {
final release = _release;
if (release == null) throw StateError('Browser ledger lock is not held');
release.complete(null);
try {
// Resolves only after navigator.locks has actually released ownership.
await _request;
} finally {
_release = null;
_request = null;
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import 'package:sembast/sembast.dart';
import 'storage_native.dart'
if (dart.library.js_interop) 'storage_web.dart'
as platform;
/// A single transaction replaces the complete ledger, pending changes and cursor.
abstract class SnapshotStorage {
Future<Map<String, dynamic>?> read(String key);
Future<void> write(String key, Map<String, dynamic> value);
Future<void> close();
}
/// Connections are opened lazily and closed after every operation, so another
/// Flutter engine never reads through an old Sembast in-memory cache. Android
/// callers serialize complete operations with the shared native store lock.
class LedgerStorage implements SnapshotStorage {
LedgerStorage._();
final _store = stringMapStoreFactory.store('snapshots');
static Future<LedgerStorage> open() async => LedgerStorage._();
@override
Future<Map<String, dynamic>?> read(String key) async {
final db = await platform.openLedgerDatabase();
try {
return await _store.record(key).get(db);
} finally {
await db.close();
}
}
@override
Future<void> write(String key, Map<String, dynamic> value) async {
final db = await platform.openLedgerDatabase();
try {
await db.transaction((txn) async {
await _store.record(key).put(txn, value);
});
} finally {
await db.close();
}
}
@override
Future<void> close() async {}
}
class MemorySnapshotStorage implements SnapshotStorage {
final Map<String, Map<String, dynamic>> values = {};
@override
Future<Map<String, dynamic>?> read(String key) async => values[key];
@override
Future<void> write(String key, Map<String, dynamic> value) async {
values[key] = value;
}
@override
Future<void> close() async {}
}
@@ -0,0 +1,8 @@
import 'package:path_provider/path_provider.dart';
import 'package:sembast/sembast_io.dart';
Future<Database> openLedgerDatabase() async {
final directory = await getApplicationSupportDirectory();
await directory.create(recursive: true);
return databaseFactoryIo.openDatabase('${directory.path}/balance.sembast');
}
@@ -0,0 +1,4 @@
import 'package:sembast_web/sembast_web.dart';
Future<Database> openLedgerDatabase() =>
databaseFactoryWeb.openDatabase('balance');
+287
View File
@@ -0,0 +1,287 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// JSON wire objects, deliberately independent of local database models.
typedef WireMap = Map<String, dynamic>;
typedef AccessTokenProvider = Future<String> Function();
class SyncApiException implements Exception {
const SyncApiException(this.code, this.message, {this.statusCode});
final String code;
final String message;
final int? statusCode;
// Do not include response bodies, request headers or tokens in diagnostics.
@override
String toString() => 'SyncApiException($code, HTTP $statusCode): $message';
}
/// Stateless HTTP transport. Session persistence belongs to the caller's secure
/// store. Never persist returned session maps in preferences or browser storage.
class SyncApi {
SyncApi({
required String serverUrl,
http.Client? client,
this.timeout = const Duration(seconds: 30),
bool allowInsecureHttp = false,
}) : _client = client ?? http.Client(),
_ownsClient = client == null,
baseUri = _normalizeServer(serverUrl, allowInsecureHttp);
final Uri baseUri;
final Duration timeout;
final http.Client _client;
final bool _ownsClient;
/// Register/login/refresh return {accessToken, refreshToken, expiresAt,
/// user: {id, email}}. Refresh rotates both tokens; save the result atomically.
Future<WireMap> register(String email, String password) =>
_authenticate('register', email, password);
Future<WireMap> login(String email, String password) =>
_authenticate('login', email, password);
Future<WireMap> _authenticate(
String action,
String email,
String password,
) async => _session(await _request('POST', 'v1/auth/$action', body: {
'email': email.trim(),
'password': password,
}));
Future<WireMap> refresh(String refreshToken) async =>
_session(await _request('POST', 'v1/auth/refresh', body: {
'refreshToken': refreshToken,
}));
/// Clear the external local session in a finally block, even when offline.
/// An offline logout cannot revoke the server refresh token.
Future<void> logout(String refreshToken) async {
await _request('POST', 'v1/auth/logout', body: {
'refreshToken': refreshToken,
});
}
Future<WireMap> me(String accessToken) async {
final result = await _request('GET', 'v1/me', accessToken: accessToken);
_string(result, 'id');
_string(result, 'email');
return result;
}
/// One wire request. Push-only calls leave cursor unchanged. The caller must
/// merge a pulled page durably before committing its returned cursor.
Future<WireMap> sync({
required String accessToken,
required int cursor,
required String deviceId,
required List<WireMap> changes,
bool pull = true,
}) async {
_cursor(cursor);
if (changes.length > 5000) {
throw ArgumentError('A sync request supports at most 5000 changes');
}
for (final change in changes) {
_validateChange(change, outgoing: true);
}
final page = await _request('POST', 'v1/sync', accessToken: accessToken,
body: _syncBody(cursor, deviceId, changes, pull));
final next = _integer(page, 'cursor');
_cursor(next);
if (next < cursor || page['hasMore'] is! bool || page['changes'] is! List) {
throw const FormatException('Invalid sync page');
}
if (page['hasMore'] == true && next <= cursor) {
throw const FormatException('Sync cursor did not advance');
}
DateTime.parse(_string(page, 'serverTime'));
final records = <WireMap>[];
var previousSequence = cursor;
for (final raw in page['changes'] as List) {
final change = _object(raw);
_validateChange(change);
final sequence = _integer(change, 'sequence');
if (sequence <= previousSequence || sequence > next) {
throw const FormatException('Invalid change sequence');
}
previousSequence = sequence;
records.add(change);
}
if (records.isNotEmpty && previousSequence != next) {
throw const FormatException('Cursor does not match the last change');
}
if (records.isEmpty && next != cursor) {
throw const FormatException('Empty page advanced the cursor');
}
return {...page, 'changes': records};
}
/// Pushes a stable snapshot in bounded batches, then pulls all pages. The
/// provider may refresh expired tokens through an external session manager.
/// applyPage must atomically merge changes and persist cursor, and must throw
/// on failure. Keep the push watermark unchanged until this method succeeds.
Future<int> synchronize({
required AccessTokenProvider accessToken,
required int cursor,
required String deviceId,
required List<WireMap> changes,
required Future<void> Function(List<WireMap> changes, int cursor) applyPage,
}) async {
// Copy the complete JSON snapshot before the first await so concurrent edits
// cannot mutate a batch while it is being uploaded.
final snapshot = (jsonDecode(jsonEncode(changes)) as List)
.map(_object).toList(growable: false);
_cursor(cursor);
for (final change in snapshot) {
_validateChange(change, outgoing: true);
}
for (var start = 0; start < snapshot.length; start += 100) {
final end = start + 100 < snapshot.length ? start + 100 : snapshot.length;
await _pushBatch(accessToken, cursor, deviceId, snapshot.sublist(start, end));
}
var next = cursor;
while (true) {
final page = await sync(accessToken: await accessToken(), cursor: next,
deviceId: deviceId, changes: const []);
next = page['cursor'] as int;
await applyPage((page['changes'] as List).cast<WireMap>(), next);
if (page['hasMore'] == false) return next;
}
}
Future<void> _pushBatch(AccessTokenProvider token, int cursor, String deviceId,
List<WireMap> changes) async {
final size = utf8.encode(jsonEncode(_syncBody(cursor, deviceId, changes, false))).length;
if (size > 1500000 && changes.length > 1) {
final middle = changes.length ~/ 2;
await _pushBatch(token, cursor, deviceId, changes.sublist(0, middle));
await _pushBatch(token, cursor, deviceId, changes.sublist(middle));
return;
}
await sync(accessToken: await token(), cursor: cursor, deviceId: deviceId,
changes: changes, pull: false);
}
Future<WireMap> _request(String method, String path, {
WireMap? body,
String? accessToken,
}) async {
final request = http.Request(method, baseUri.resolve(path));
request.followRedirects = false;
request.headers['Accept'] = 'application/json';
if (accessToken != null) {
request.headers['Authorization'] = 'Bearer $accessToken';
}
if (body != null) {
request.headers['Content-Type'] = 'application/json; charset=utf-8';
request.bodyBytes = utf8.encode(jsonEncode(body));
if (request.bodyBytes.length > 2 * 1024 * 1024) {
throw ArgumentError('Request exceeds the server 2 MiB limit');
}
}
final response = await (() async {
final stream = await _client.send(request);
return http.Response.fromStream(stream);
})().timeout(timeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
var code = 'http_error';
var message = 'Server request failed';
try {
final detail = _object(_object(jsonDecode(response.body))['error']);
code = _string(detail, 'code');
message = _string(detail, 'message');
} on FormatException {
// Proxies may return HTML. Never surface raw bodies containing secrets.
}
throw SyncApiException(code, message, statusCode: response.statusCode);
}
if (response.statusCode == 204) return <String, dynamic>{};
return _object(jsonDecode(utf8.decode(response.bodyBytes)));
}
void close() {
if (_ownsClient) _client.close();
}
static WireMap _syncBody(int cursor, String deviceId,
List<WireMap> changes, bool pull) => {
'cursor': cursor, 'deviceId': deviceId, 'changes': changes, 'pull': pull,
};
static WireMap _session(WireMap value) {
_string(value, 'accessToken');
_string(value, 'refreshToken');
DateTime.parse(_string(value, 'expiresAt'));
final user = _object(value['user']);
_string(user, 'id');
_string(user, 'email');
return value;
}
static void _validateChange(WireMap change, {bool outgoing = false}) {
if (!const {'transaction', 'category', 'budget'}.contains(change['entity'])) {
throw const FormatException('Unknown sync entity');
}
final id = _string(change, 'id');
if (utf8.encode(id).length > 128 || change['deleted'] is! bool) {
throw const FormatException('Invalid record ID or deletion flag');
}
DateTime.parse(_string(change, 'updatedAt'));
if (change['deleted'] == false) {
_object(change['payload']);
}
if (outgoing) {
const keys = {'entity', 'id', 'deleted', 'updatedAt', 'payload', 'version', 'sequence'};
if (change.keys.any((key) => !keys.contains(key))) {
throw const FormatException('Unknown change property');
}
if (change.containsKey('payload') &&
utf8.encode(jsonEncode(change['payload'])).length > 256 * 1024) {
throw const FormatException('Payload exceeds 256 KiB');
}
}
}
static WireMap _object(dynamic value) {
if (value is! Map<String, dynamic>) {
throw const FormatException('Expected a JSON object');
}
return value;
}
static String _string(WireMap value, String key) {
final result = value[key];
if (result is! String || result.isEmpty) {
throw FormatException('Missing or invalid $key');
}
return result;
}
static int _integer(WireMap value, String key) {
final result = value[key];
if (result is! int) throw FormatException('Missing or invalid $key');
return result;
}
static void _cursor(int value) {
// JS numbers cannot faithfully round-trip the full server int64 range.
if (value < 0 || value > 9007199254740991) {
throw ArgumentError('Cursor is outside the portable integer range');
}
}
static Uri _normalizeServer(String value, bool allowInsecureHttp) {
final uri = Uri.parse(value.trim());
if (!uri.hasAuthority || uri.host.isEmpty || uri.userInfo.isNotEmpty ||
uri.hasQuery || uri.hasFragment ||
!(uri.scheme == 'https' || (allowInsecureHttp && uri.scheme == 'http'))) {
throw ArgumentError('Use an HTTPS server URL without credentials, query or fragment');
}
return uri.replace(path: '${uri.path.replaceAll(RegExp(r'/+$'), '')}/');
}
}