From 68f79570432c339a715e962d1f471a336109ed78 Mon Sep 17 00:00:00 2001 From: sevenhill Date: Thu, 8 Oct 2026 06:22:47 +0300 Subject: [PATCH] Fix phonebook refresh flicker and duplicate Android recent tasks --- android/app/build.gradle.kts | 1 + android/app/src/main/AndroidManifest.xml | 3 +- .../main/kotlin/ru/qlyra/app/MainActivity.kt | 34 +++++++ .../kotlin/ru/qlyra/app/RecentAppTasks.kt | 36 ++++++++ .../kotlin/ru/qlyra/app/RecentTaskPolicy.kt | 19 ++++ .../ru/ikar/app/RecentTaskPolicyTest.kt | 41 +++++++++ .../contacts/device_contacts_service.dart | 30 +++++-- lib/core/utils/update_installer.dart | 17 ++-- pubspec.yaml | 2 +- test/device_contact_names_refresh_test.dart | 88 +++++++++++++++++++ 10 files changed, 253 insertions(+), 18 deletions(-) create mode 100644 android/app/src/main/kotlin/ru/qlyra/app/RecentAppTasks.kt create mode 100644 android/app/src/main/kotlin/ru/qlyra/app/RecentTaskPolicy.kt create mode 100644 android/app/src/test/kotlin/ru/ikar/app/RecentTaskPolicyTest.kt create mode 100644 test/device_contact_names_refresh_test.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 51b3545..2d4df1e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -179,6 +179,7 @@ android.applicationVariants.all { } dependencies { + testImplementation("junit:junit:4.13.2") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") implementation("androidx.media3:media3-transformer:1.9.3") implementation("androidx.media3:media3-effect:1.9.3") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d7583f8..93549ec 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -51,7 +51,8 @@ android:icon="@mipmap/ic_launcher" android:label="ИКАР" android:launchMode="singleTask" - android:taskAffinity="" + android:taskAffinity="ru.ikar.app" + android:documentLaunchMode="never" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt index 8a10efb..23e1d31 100644 --- a/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt @@ -145,6 +145,10 @@ class MainActivity : AudioServiceFragmentActivity() { result.success(apkSignerMatchesInstalled(path)) } } + "launchApkInstaller" -> { + val path = call.argument("path") + result.success(path != null && launchApkInstaller(path)) + } else -> result.notImplemented() } } @@ -582,7 +586,36 @@ class MainActivity : AudioServiceFragmentActivity() { }.toSet() } + private fun launchApkInstaller(path: String): Boolean { + return try { + val apk = File(path).canonicalFile + if (!apk.isFile || + !apk.path.startsWith(cacheDir.canonicalPath + File.separator) || + !apkSignerMatchesInstalled(apk.path) + ) return false + val uri = androidx.core.content.FileProvider.getUriForFile( + this, + "$packageName.fileProvider.com.crazecoder.openfile", + apk, + ) + val installer = Intent(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or + Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + startActivity(installer) + true + } catch (_: Exception) { + false + } + } + override fun onCreate(savedInstanceState: Bundle?) { + RecentAppTasks.removeDuplicates(this) if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags() super.onCreate(savedInstanceState) applyKeepAwake() @@ -594,6 +627,7 @@ class MainActivity : AudioServiceFragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) + RecentAppTasks.removeDuplicates(this) if (intent.hasExtra(CallConst.EXTRA_CALL)) { applyCallWindowFlags() stashCall(intent, emit = true) diff --git a/android/app/src/main/kotlin/ru/qlyra/app/RecentAppTasks.kt b/android/app/src/main/kotlin/ru/qlyra/app/RecentAppTasks.kt new file mode 100644 index 0000000..9818f9a --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/RecentAppTasks.kt @@ -0,0 +1,36 @@ +package ru.ikar.app + +import android.app.Activity +import android.app.ActivityManager +import android.os.Build +import android.util.Log + +/** Removes old launcher cards retained across an APK replacement. */ +internal object RecentAppTasks { + fun removeDuplicates(activity: Activity) { + val manager = activity.getSystemService(ActivityManager::class.java) ?: return + val tasks = runCatching { manager.appTasks }.getOrElse { return } + for (task in tasks) { + try { + val info = task.taskInfo ?: continue + @Suppress("DEPRECATION") + val id = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + info.taskId + } else { + info.persistentId + } + val root = info.baseIntent.component ?: info.baseActivity + if (RecentTaskPolicy.isDuplicateHome( + activity.taskId, id, activity.packageName, + root?.packageName, root?.className, + ) + ) { + task.finishAndRemoveTask() + } + } catch (_: Exception) { + // OEM task snapshots can disappear between enumeration and removal. + Log.w("IkarRecentTasks", "A stale task could not be removed") + } + } + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/RecentTaskPolicy.kt b/android/app/src/main/kotlin/ru/qlyra/app/RecentTaskPolicy.kt new file mode 100644 index 0000000..65acf7c --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/RecentTaskPolicy.kt @@ -0,0 +1,19 @@ +package ru.ikar.app + +internal object RecentTaskPolicy { + fun isDuplicateHome( + currentId: Int, + candidateId: Int, + applicationPackage: String, + rootPackage: String?, + rootClass: String?, + ): Boolean { + if (candidateId < 0 || candidateId == currentId) return false + if (rootPackage != applicationPackage) return false + return rootClass in setOf( + "$applicationPackage.MainActivity", + "$applicationPackage.MinimalIcon", + "$applicationPackage.IntentEntry", + ) + } +} diff --git a/android/app/src/test/kotlin/ru/ikar/app/RecentTaskPolicyTest.kt b/android/app/src/test/kotlin/ru/ikar/app/RecentTaskPolicyTest.kt new file mode 100644 index 0000000..243f6ca --- /dev/null +++ b/android/app/src/test/kotlin/ru/ikar/app/RecentTaskPolicyTest.kt @@ -0,0 +1,41 @@ +package ru.ikar.app + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecentTaskPolicyTest { + private val app = "ru.ikar.app" + + @Test + fun currentWindowIsNeverRemoved() { + assertFalse(RecentTaskPolicy.isDuplicateHome(12, 12, app, app, "$app.MainActivity")) + } + + @Test + fun allOldHomeEntryPointsAreRecognized() { + for (entry in listOf("MainActivity", "MinimalIcon", "IntentEntry")) { + assertTrue(RecentTaskPolicy.isDuplicateHome(12, 9, app, app, "$app.$entry")) + } + } + + @Test + fun anotherApplicationsTaskIsPreserved() { + assertFalse(RecentTaskPolicy.isDuplicateHome(12, 9, app, "com.android.packageinstaller", "$app.MainActivity")) + } + + @Test + fun otherAppActivitiesArePreserved() { + assertFalse(RecentTaskPolicy.isDuplicateHome(12, 9, app, app, "$app.MediaPicker")) + } + + @Test + fun disappearedTaskIsIgnored() { + assertFalse(RecentTaskPolicy.isDuplicateHome(12, -1, app, app, "$app.MainActivity")) + } + + @Test + fun unidentifiedRootIsPreserved() { + assertFalse(RecentTaskPolicy.isDuplicateHome(12, 9, app, null, null)) + } +} diff --git a/lib/core/contacts/device_contacts_service.dart b/lib/core/contacts/device_contacts_service.dart index 5fd1a6a..7037638 100644 --- a/lib/core/contacts/device_contacts_service.dart +++ b/lib/core/contacts/device_contacts_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -84,11 +85,17 @@ class DeviceContactsService { return _readBook(); } - static Future reload() async { + static Future reload() => ensureLoadedInteractive(force: true); + + @visibleForTesting + static Future readBookForTesting() => _readBook(); + + @visibleForTesting + static void resetMemoryForTesting() { _loaded = false; _byLast10.clear(); _phonebook.clear(); - return ensureLoadedInteractive(force: true); + _readInFlight = null; } static Future _forgetDenial() async { @@ -100,30 +107,35 @@ class DeviceContactsService { .whenComplete(() => _readInFlight = null); static Future _readBookOnce() async { - _loaded = false; - _byLast10.clear(); - _phonebook.clear(); try { FlutterContacts.config.includeNonVisibleOnAndroid = true; final contacts = await FlutterContacts.getContacts(withProperties: true); - _byLast10.clear(); - _phonebook.clear(); + final nextByLast10 = {}; + final nextPhonebook = {}; for (final contact in contacts) { final name = contact.displayName.trim(); for (final phone in contact.phones) { final normalized = normalizePhone(phone.number); if (normalized != null) { - _phonebook.putIfAbsent( + nextPhonebook.putIfAbsent( normalized, () => name.isEmpty ? normalized : name, ); } final key = _last10(phone.number); if (key != null) { - _byLast10.putIfAbsent(key, () => name); + nextByLast10.putIfAbsent(key, () => name); } } } + // Keep the last complete snapshot while the native reader is awaiting. + // Replacing both maps synchronously prevents a fallback to server names. + _byLast10 + ..clear() + ..addAll(nextByLast10); + _phonebook + ..clear() + ..addAll(nextPhonebook); _loaded = true; logger.i( 'Телефонная книга: прочитано ${contacts.length} записей, ' diff --git a/lib/core/utils/update_installer.dart b/lib/core/utils/update_installer.dart index 9921034..6bde26f 100644 --- a/lib/core/utils/update_installer.dart +++ b/lib/core/utils/update_installer.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/services.dart'; -import 'package:open_filex/open_filex.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path_provider/path_provider.dart'; @@ -70,14 +69,18 @@ abstract class UpdateInstaller { ); } - final opened = await OpenFilex.open( - file.path, - type: 'application/vnd.android.package-archive', - ); - if (opened.type != ResultType.done) { + try { + final launched = await _integrityChannel.invokeMethod( + 'launchApkInstaller', + {'path': file.path}, + ); + if (launched != true) { + return const UpdateInstallResult(UpdateInstallStatus.installFailed); + } + } on PlatformException catch (error) { return UpdateInstallResult( UpdateInstallStatus.installFailed, - error: opened.message, + error: error.message, ); } return const UpdateInstallResult(UpdateInstallStatus.done); diff --git a/pubspec.yaml b/pubspec.yaml index f99d62c..aa15350 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.5+6 +version: 1.0.6+7 environment: sdk: ^3.10.4 diff --git a/test/device_contact_names_refresh_test.dart b/test/device_contact_names_refresh_test.dart new file mode 100644 index 0000000..1b2566d --- /dev/null +++ b/test/device_contact_names_refresh_test.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ikar/backend/modules/messages.dart'; +import 'package:ikar/core/config/app_phonebook_names.dart'; +import 'package:ikar/core/contacts/device_contacts_service.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +const phone = 79000000007; +const channel = MethodChannel('github.com/QuisApp/flutter_contacts'); + +List> book(String name) => [ + Contact(id: '7', displayName: name, phones: [Phone('+79000000007')]).toJson(), +]; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late Future>> Function() read; + setUp(() { + SharedPreferences.setMockInitialValues({}); + ContactCache.resetMemoryForTesting(); + DeviceContactsService.resetMemoryForTesting(); + AppPhonebookNames.current.value = true; + read = () async => book('Имя из контактов'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) { + expect(call.method, 'select'); + return read(); + }); + ContactCache.putPhone(7, phone); + ContactCache.put(7, 'Серверное имя'); + }); + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + ContactCache.resetMemoryForTesting(); + DeviceContactsService.resetMemoryForTesting(); + }); + + testWidgets('chat name stays local throughout a delayed phonebook refresh', ( + tester, + ) async { + expect(await DeviceContactsService.readBookForTesting(), isTrue); + final pending = Completer>>(); + read = () => pending.future; + final refresh = DeviceContactsService.readBookForTesting(); + await tester.pump(); + // A server response and widget rebuild arrive while the native reader waits. + ContactCache.put(7, 'Обновлённое серверное имя'); + await tester.pumpWidget(MaterialApp(home: Text(ContactCache.get(7)!))); + expect(find.text('Имя из контактов'), findsOneWidget); + expect(DeviceContactsService.isLoaded, isTrue); + expect(DeviceContactsService.phonebook['+79000000007'], 'Имя из контактов'); + await tester.pump(const Duration(seconds: 1)); + expect(ContactCache.get(7), 'Имя из контактов'); + pending.complete(book('Имя из контактов')); + await refresh; + await tester.pumpWidget(MaterialApp(home: Text(ContactCache.get(7)!))); + expect(find.text('Имя из контактов'), findsOneWidget); + expect(find.text('Обновлённое серверное имя'), findsNothing); + await tester.pump(const Duration(seconds: 3)); + expect(ContactCache.get(7), 'Имя из контактов'); + }); + + test('a failed refresh keeps the last successfully loaded names', () async { + await DeviceContactsService.readBookForTesting(); + read = () async => throw PlatformException(code: 'READ_FAILED'); + expect(await DeviceContactsService.readBookForTesting(), isFalse); + expect(ContactCache.get(7), 'Имя из контактов'); + expect(DeviceContactsService.isLoaded, isTrue); + expect(DeviceContactsService.knownNumbers, 1); + }); + + test('a successful refresh applies renamed and deleted contacts', () async { + await DeviceContactsService.readBookForTesting(); + read = () async => book('Новое имя'); + expect(await DeviceContactsService.readBookForTesting(), isTrue); + expect(ContactCache.get(7), 'Новое имя'); + read = () async => []; + expect(await DeviceContactsService.readBookForTesting(), isTrue); + expect(DeviceContactsService.knownNumbers, 0); + expect(DeviceContactsService.phonebook, isEmpty); + expect(ContactCache.get(7), 'Серверное имя'); + }); +}