fix: скрытие удаленных аккаунтов с контактов, возможно фикс скрытия медиа дорожки в кружках на кастомных android прошивках версии android ниже 16

This commit is contained in:
Jganenokk
2026-08-22 21:54:11 +07:00
parent 6dc1835377
commit 0091b5f272
9 changed files with 296 additions and 29 deletions
+23 -4
View File
@@ -21,6 +21,7 @@ class CachedContact {
final String? baseRawUrl; final String? baseRawUrl;
final int updateTime; final int updateTime;
final Set<String> options; final Set<String> options;
final int accountStatus;
const CachedContact({ const CachedContact({
required this.id, required this.id,
@@ -33,12 +34,14 @@ class CachedContact {
this.baseRawUrl, this.baseRawUrl,
required this.updateTime, required this.updateTime,
this.options = const {}, this.options = const {},
this.accountStatus = 0,
}); });
bool get isOfficial => options.contains('OFFICIAL'); bool get isOfficial => options.contains('OFFICIAL');
bool get isBot => options.contains('BOT'); bool get isBot => options.contains('BOT');
bool get isServiceAccount => options.contains('SERVICE_ACCOUNT'); bool get isServiceAccount => options.contains('SERVICE_ACCOUNT');
bool get isVerified => isOfficial; bool get isVerified => isOfficial;
bool get isDeleted => accountStatus != 0;
factory CachedContact.fromDbRow(Map<String, dynamic> row) => CachedContact( factory CachedContact.fromDbRow(Map<String, dynamic> row) => CachedContact(
id: row['id'] as int, id: row['id'] as int,
@@ -51,6 +54,7 @@ class CachedContact {
baseRawUrl: row['base_raw_url'] as String?, baseRawUrl: row['base_raw_url'] as String?,
updateTime: row['update_time'] as int, updateTime: row['update_time'] as int,
options: _decodeOptions(row['options']), options: _decodeOptions(row['options']),
accountStatus: (row['account_status'] as int?) ?? 0,
); );
static Set<String> _decodeOptions(dynamic raw) { static Set<String> _decodeOptions(dynamic raw) {
@@ -183,6 +187,7 @@ class ContactsModule {
'base_raw_url': null, 'base_raw_url': null,
'update_time': 0, 'update_time': 0,
'options': null, 'options': null,
'account_status': 0,
}; };
if (row == null) return null; if (row == null) return null;
@@ -397,7 +402,14 @@ class ContactsModule {
int accountId, int accountId,
) async { ) async {
final contacts = data['contacts']; final contacts = data['contacts'];
if (contacts is! List || contacts.isEmpty) return; if (contacts is! List) {
logger.i('Контакты: сервер не прислал список (акк $accountId)');
return;
}
if (contacts.isEmpty) {
logger.i('Контакты: сервер прислал пустой список (акк $accountId)');
return;
}
final rows = <Map<String, dynamic>>[]; final rows = <Map<String, dynamic>>[];
for (final raw in contacts.whereType<Map>()) { for (final raw in contacts.whereType<Map>()) {
@@ -492,8 +504,14 @@ class ContactsModule {
return photos; return photos;
} }
static Future<List<CachedContact>> getContacts(int accountId) async { static Future<List<CachedContact>> getContacts(
final rows = await AppDatabase.loadContacts(accountId); int accountId, {
bool includeDeleted = false,
}) async {
final rows = await AppDatabase.loadContacts(
accountId,
includeDeleted: includeDeleted,
);
return rows.map(CachedContact.fromDbRow).toList(); return rows.map(CachedContact.fromDbRow).toList();
} }
@@ -571,7 +589,7 @@ class ContactsModule {
/// Прогревает in-memory ContactCache из локальных контактов. /// Прогревает in-memory ContactCache из локальных контактов.
/// Нужно вызывать на cold start: иначе кэш пуст до следующего логина. /// Нужно вызывать на cold start: иначе кэш пуст до следующего логина.
static Future<void> primeCacheFromDb(int accountId) async { static Future<void> primeCacheFromDb(int accountId) async {
final contacts = await getContacts(accountId); final contacts = await getContacts(accountId, includeDeleted: true);
for (final c in contacts) { for (final c in contacts) {
ContactCache.putPhone(c.id, c.phone); ContactCache.putPhone(c.id, c.phone);
final fullName = (c.lastName != null && c.lastName!.isNotEmpty) final fullName = (c.lastName != null && c.lastName!.isNotEmpty)
@@ -632,6 +650,7 @@ class ContactsModule {
'base_raw_url': contact['baseRawUrl'] as String?, 'base_raw_url': contact['baseRawUrl'] as String?,
'update_time': (contact['updateTime'] as int?) ?? 0, 'update_time': (contact['updateTime'] as int?) ?? 0,
'options': optionsStr, 'options': optionsStr,
'account_status': (contact['accountStatus'] as int?) ?? 0,
}; };
} }
} }
+40 -9
View File
@@ -1,14 +1,15 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_phonebook_names.dart'; import '../config/app_phonebook_names.dart';
import '../utils/logger.dart';
class DeviceContactsService { class DeviceContactsService {
DeviceContactsService._(); DeviceContactsService._();
static const _grantedKey = 'phonebook_granted';
static const _deniedKey = 'phonebook_denied'; static const _deniedKey = 'phonebook_denied';
static final Map<String, String> _byLast10 = {}; static final Map<String, String> _byLast10 = {};
@@ -16,6 +17,10 @@ class DeviceContactsService {
static bool get _supported => Platform.isAndroid || Platform.isIOS; static bool get _supported => Platform.isAndroid || Platform.isIOS;
static bool get isLoaded => _loaded;
static int get knownNumbers => _byLast10.length;
static String? _last10(String raw) { static String? _last10(String raw) {
final digits = raw.replaceAll(RegExp(r'[^\d]'), ''); final digits = raw.replaceAll(RegExp(r'[^\d]'), '');
if (digits.length < 10) return null; if (digits.length < 10) return null;
@@ -32,28 +37,43 @@ class DeviceContactsService {
return name.trim(); return name.trim();
} }
static Future<bool> hasPermission() async {
if (!_supported) return false;
try {
return await Permission.contacts.isGranted;
} catch (e) {
logger.w('Телефонная книга: не удалось прочитать статус разрешения: $e');
return false;
}
}
static Future<void> loadFromStartup() async { static Future<void> loadFromStartup() async {
if (_loaded || !_supported) return; if (_loaded || !_supported) return;
if (!AppPhonebookNames.current.value) return; if (!AppPhonebookNames.current.value) return;
final prefs = await SharedPreferences.getInstance(); if (!await hasPermission()) return;
if (prefs.getBool(_grantedKey) != true) return; await _forgetDenial();
final granted = await FlutterContacts.requestPermission(readonly: true);
if (!granted) return;
await _readBook(); await _readBook();
} }
static Future<bool> ensureLoadedInteractive({bool force = false}) async { static Future<bool> ensureLoadedInteractive({bool force = false}) async {
if (_loaded || !_supported) return false; if (!_supported) return false;
if (!AppPhonebookNames.current.value) return false; if (!AppPhonebookNames.current.value) return false;
if (_loaded && !force) return false;
if (await hasPermission()) {
await _forgetDenial();
return _readBook();
}
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!force && prefs.getBool(_deniedKey) == true) return false; if (!force && prefs.getBool(_deniedKey) == true) return false;
final granted = await FlutterContacts.requestPermission(readonly: true); final granted = await FlutterContacts.requestPermission(readonly: true);
if (!granted) { if (!granted) {
await prefs.setBool(_deniedKey, true); await prefs.setBool(_deniedKey, true);
return false; return false;
} }
await prefs.remove(_deniedKey); await prefs.remove(_deniedKey);
await prefs.setBool(_grantedKey, true);
return _readBook(); return _readBook();
} }
@@ -63,8 +83,14 @@ class DeviceContactsService {
return ensureLoadedInteractive(force: true); return ensureLoadedInteractive(force: true);
} }
static Future<void> _forgetDenial() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool(_deniedKey) == true) await prefs.remove(_deniedKey);
}
static Future<bool> _readBook() async { static Future<bool> _readBook() async {
try { try {
FlutterContacts.config.includeNonVisibleOnAndroid = true;
final contacts = await FlutterContacts.getContacts(withProperties: true); final contacts = await FlutterContacts.getContacts(withProperties: true);
_byLast10.clear(); _byLast10.clear();
for (final contact in contacts) { for (final contact in contacts) {
@@ -78,8 +104,13 @@ class DeviceContactsService {
} }
} }
_loaded = true; _loaded = true;
return _byLast10.isNotEmpty; logger.i(
} catch (_) { 'Телефонная книга: прочитано ${contacts.length} записей, '
'${_byLast10.length} номеров',
);
return true;
} catch (e) {
logger.w('Телефонная книга: не удалось прочитать: $e');
return false; return false;
} }
} }
+11
View File
@@ -0,0 +1,11 @@
import 'dart:ui';
Size videoNoteFrameSize(Size frame, double fallback) {
final width = frame.width.isFinite && frame.width > 0
? frame.width
: fallback;
final height = frame.height.isFinite && frame.height > 0
? frame.height
: fallback;
return Size(width, height);
}
+19 -4
View File
@@ -221,7 +221,7 @@ class AppDatabase {
await _migrateLegacyDb(target); await _migrateLegacyDb(target);
return openDatabase( return openDatabase(
target, target,
version: 22, version: 23,
onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'), onOpen: (db) => db.execute('PRAGMA foreign_keys = ON'),
onCreate: (db, _) => _createTables(db), onCreate: (db, _) => _createTables(db),
onUpgrade: (db, oldVersion, newVersion) async { onUpgrade: (db, oldVersion, newVersion) async {
@@ -353,6 +353,14 @@ class AppDatabase {
await db.execute(_webAppStorageSchema); await db.execute(_webAppStorageSchema);
await db.execute(_webAppBiometrySchema); await db.execute(_webAppBiometrySchema);
} }
if (oldVersion < 23) {
await _addColumnIfMissing(
db,
'contacts',
'account_status',
'INTEGER NOT NULL DEFAULT 0',
);
}
}, },
); );
} }
@@ -465,7 +473,8 @@ class AppDatabase {
base_url TEXT, base_url TEXT,
base_raw_url TEXT, base_raw_url TEXT,
update_time INTEGER NOT NULL DEFAULT 0, update_time INTEGER NOT NULL DEFAULT 0,
options TEXT options TEXT,
account_status INTEGER NOT NULL DEFAULT 0
) )
'''; ''';
@@ -798,6 +807,7 @@ class AppDatabase {
static Future<void> close() async { static Future<void> close() async {
await _db?.close(); await _db?.close();
_db = null; _db = null;
_initCompleter = null;
} }
// Chats cache // Chats cache
@@ -1045,11 +1055,16 @@ class AppDatabase {
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
static Future<List<Map<String, dynamic>>> loadContacts(int accountId) async { static Future<List<Map<String, dynamic>>> loadContacts(
int accountId, {
bool includeDeleted = false,
}) async {
final db = await _instance; final db = await _instance;
return db.query( return db.query(
'contacts', 'contacts',
where: 'account_id = ?', where: includeDeleted
? 'account_id = ?'
: 'account_id = ? AND account_status = 0',
whereArgs: [accountId], whereArgs: [accountId],
); );
} }
@@ -863,6 +863,7 @@ class _ChatListScreenState extends State<ChatListScreen>
final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id); final foldersKnown = await FoldersModule.hasReceivedFoldersList(p.id);
final contactIds = (await ContactsModule.getContacts( final contactIds = (await ContactsModule.getContacts(
p.id, p.id,
includeDeleted: true,
)).map((c) => c.id).toSet(); )).map((c) => c.id).toSet();
const allChatsFolder = ChatFolder( const allChatsFolder = ChatFolder(
@@ -11,6 +11,7 @@ import 'package:video_player/video_player.dart';
import 'package:komet/main.dart'; import 'package:komet/main.dart';
import '../../../../core/media/media_playback.dart'; import '../../../../core/media/media_playback.dart';
import '../../../../core/media/video_note_frame.dart';
import '../../../../core/media/video_note_preloader.dart'; import '../../../../core/media/video_note_preloader.dart';
import '../../../../core/utils/format.dart'; import '../../../../core/utils/format.dart';
import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/haptics.dart';
@@ -65,6 +66,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
late final AnimationController _expand; late final AnimationController _expand;
final ValueNotifier<double> _ringProgress = ValueNotifier(0); final ValueNotifier<double> _ringProgress = ValueNotifier(0);
Uint8List? _preview; Uint8List? _preview;
Size _frameSize = Size.zero;
VideoPlayerController? _controller; VideoPlayerController? _controller;
VideoPlayerController? _local; VideoPlayerController? _local;
Future<void>? _initializing; Future<void>? _initializing;
@@ -195,8 +197,11 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
? (value.position.inMilliseconds / total).clamp(0.0, 1.0) ? (value.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0; : 0.0;
} }
if (value.isPlaying != _playing) { if (value.size != _frameSize || value.isPlaying != _playing) {
setState(() => _playing = value.isPlaying); setState(() {
_frameSize = value.size;
_playing = value.isPlaying;
});
} }
} }
@@ -239,7 +244,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
_controller = live; _controller = live;
live.addListener(_onTick); live.addListener(_onTick);
_PreviewPool.pin(this); _PreviewPool.pin(this);
if (mounted) setState(() {}); if (mounted) setState(() => _frameSize = live.value.size);
return live; return live;
} }
final running = _initializing; final running = _initializing;
@@ -272,7 +277,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
await controller.seekTo(Duration.zero); await controller.seekTo(Duration.zero);
controller.addListener(_onTick); controller.addListener(_onTick);
_PreviewPool.register(this); _PreviewPool.register(this);
if (mounted) setState(() {}); if (mounted) setState(() => _frameSize = controller.value.size);
return controller; return controller;
} }
@@ -283,7 +288,7 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
_controller = null; _controller = null;
controller.removeListener(_onTick); controller.removeListener(_onTick);
MediaPlayback.instance.releaseVideoNote(controller); MediaPlayback.instance.releaseVideoNote(controller);
if (mounted) setState(() {}); if (mounted) setState(() => _frameSize = Size.zero);
} }
Future<void> _toggle() async { Future<void> _toggle() async {
@@ -471,9 +476,14 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
? _videoSurface( ? _videoSurface(
_controller!, _controller!,
const ValueKey('note-video'), const ValueKey('note-video'),
size,
) )
: local != null && local.value.isInitialized : local != null && local.value.isInitialized
? _videoSurface(local, const ValueKey('note-local')) ? _videoSurface(
local,
const ValueKey('note-local'),
size,
)
: _buildPoster(preview), : _buildPoster(preview),
), ),
), ),
@@ -559,15 +569,20 @@ class _VideoNoteBubbleState extends State<VideoNoteBubble>
); );
} }
Widget _videoSurface(VideoPlayerController controller, Key key) { Widget _videoSurface(
VideoPlayerController controller,
Key key,
double fallback,
) {
final frame = videoNoteFrameSize(controller.value.size, fallback);
return SizedBox.expand( return SizedBox.expand(
key: key, key: key,
child: FittedBox( child: FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: SizedBox( child: SizedBox(
width: controller.value.size.width, width: frame.width,
height: controller.value.size.height, height: frame.height,
child: VideoPlayer(controller), child: VideoPlayer(controller),
), ),
), ),
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
import '../../core/media/media_playback.dart'; import '../../core/media/media_playback.dart';
import '../../core/media/video_note_frame.dart';
import '../../core/utils/haptics.dart'; import '../../core/utils/haptics.dart';
import 'draggable_floating_layer.dart'; import 'draggable_floating_layer.dart';
@@ -65,7 +66,7 @@ class _NoteCircle extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final frame = track.controller.value.size; final frame = videoNoteFrameSize(track.controller.value.size, size);
return SizedBox( return SizedBox(
width: size, width: size,
height: size, height: size,
@@ -81,8 +82,8 @@ class _NoteCircle extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
clipBehavior: Clip.hardEdge, clipBehavior: Clip.hardEdge,
child: SizedBox( child: SizedBox(
width: frame.width <= 0 ? size : frame.width, width: frame.width,
height: frame.height <= 0 ? size : frame.height, height: frame.height,
child: VideoPlayer(track.controller), child: VideoPlayer(track.controller),
), ),
), ),
+129
View File
@@ -0,0 +1,129 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/backend/modules/contacts.dart';
import 'package:komet/core/storage/app_database.dart';
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
class _SyntheticPathProvider extends PathProviderPlatform
with MockPlatformInterfaceMixin {
final String directory;
_SyntheticPathProvider(this.directory);
@override
Future<String?> getApplicationSupportPath() async => directory;
}
Map<dynamic, dynamic> _serverContact({
required int id,
required String firstName,
required int phone,
int accountStatus = 0,
}) {
return {
'id': id,
'phone': phone,
'updateTime': 1,
'accountStatus': accountStatus,
'names': [
{'type': 'ONEME', 'firstName': firstName, 'lastName': 'Synthetic'},
],
};
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const accountId = 1;
setUp(() async {
final directory = Directory.systemTemp.createTempSync(
'synthetic_contacts_deleted_test',
);
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
addTearDown(() async {
await AppDatabase.close();
if (directory.existsSync()) directory.deleteSync(recursive: true);
});
await AppDatabase.init();
await AppDatabase.saveProfile(
ProfileData(
id: accountId,
firstName: 'Synthetic owner',
phone: 100000,
country: 'ZZ',
accountStatus: 0,
updateTime: 1,
),
);
await ContactsModule.syncFromLoginPayload({
'contacts': [
_serverContact(id: 11, firstName: 'Alive', phone: 700000011),
_serverContact(
id: 12,
firstName: 'Gone',
phone: 700000012,
accountStatus: 2,
),
],
}, accountId);
});
test('deleted accounts are hidden from the contact list', () async {
final visible = await ContactsModule.getContacts(accountId);
expect(visible.map((c) => c.id), [11]);
expect(visible.single.isDeleted, isFalse);
});
test('deleted accounts stay available when explicitly requested', () async {
final all = await ContactsModule.getContacts(
accountId,
includeDeleted: true,
);
expect(all.map((c) => c.id).toSet(), {11, 12});
expect(all.firstWhere((c) => c.id == 12).isDeleted, isTrue);
});
test(
'a re-synced deleted account does not come back into the list',
() async {
await ContactsModule.syncFromLoginPayload({
'contacts': [
_serverContact(
id: 12,
firstName: 'Gone',
phone: 700000012,
accountStatus: 2,
),
],
}, accountId);
final visible = await ContactsModule.getContacts(accountId);
expect(visible.map((c) => c.id), [11]);
},
);
test('a contact without accountStatus is treated as alive', () async {
await ContactsModule.syncFromLoginPayload({
'contacts': [
{
'id': 13,
'phone': 700000013,
'updateTime': 1,
'names': [
{'type': 'ONEME', 'firstName': 'Legacy', 'lastName': 'Synthetic'},
],
},
],
}, accountId);
final visible = await ContactsModule.getContacts(accountId);
expect(visible.map((c) => c.id).toSet(), {11, 13});
});
}
+45
View File
@@ -0,0 +1,45 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:komet/core/media/video_note_frame.dart';
void main() {
const fallback = 220.0;
test('a reported frame is used as is', () {
expect(
videoNoteFrameSize(const Size(480, 480), fallback),
const Size(480, 480),
);
expect(
videoNoteFrameSize(const Size(640, 360), fallback),
const Size(640, 360),
);
});
test('an unreported frame falls back to a square of the circle size', () {
expect(videoNoteFrameSize(Size.zero, fallback), const Size(220, 220));
});
test('a half-reported frame keeps the dimension it does have', () {
expect(
videoNoteFrameSize(const Size(480, 0), fallback),
const Size(480, 220),
);
expect(
videoNoteFrameSize(const Size(0, 480), fallback),
const Size(220, 480),
);
});
test('negative and non-finite dimensions fall back', () {
expect(
videoNoteFrameSize(const Size(-1, -1), fallback),
const Size(220, 220),
);
expect(
videoNoteFrameSize(const Size(double.nan, double.infinity), fallback),
const Size(220, 220),
);
});
}