Fix phonebook refresh flicker and duplicate Android recent tasks
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-08 06:22:47 +03:00
parent 6942981674
commit 68f7957043
10 changed files with 253 additions and 18 deletions
+1
View File
@@ -179,6 +179,7 @@ android.applicationVariants.all {
} }
dependencies { dependencies {
testImplementation("junit:junit:4.13.2")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
implementation("androidx.media3:media3-transformer:1.9.3") implementation("androidx.media3:media3-transformer:1.9.3")
implementation("androidx.media3:media3-effect:1.9.3") implementation("androidx.media3:media3-effect:1.9.3")
+2 -1
View File
@@ -51,7 +51,8 @@
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="ИКАР" android:label="ИКАР"
android:launchMode="singleTask" android:launchMode="singleTask"
android:taskAffinity="" android:taskAffinity="ru.ikar.app"
android:documentLaunchMode="never"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -145,6 +145,10 @@ class MainActivity : AudioServiceFragmentActivity() {
result.success(apkSignerMatchesInstalled(path)) result.success(apkSignerMatchesInstalled(path))
} }
} }
"launchApkInstaller" -> {
val path = call.argument<String>("path")
result.success(path != null && launchApkInstaller(path))
}
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
@@ -582,7 +586,36 @@ class MainActivity : AudioServiceFragmentActivity() {
}.toSet() }.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?) { override fun onCreate(savedInstanceState: Bundle?) {
RecentAppTasks.removeDuplicates(this)
if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags() if (intent?.hasExtra(CallConst.EXTRA_CALL) == true) applyCallWindowFlags()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applyKeepAwake() applyKeepAwake()
@@ -594,6 +627,7 @@ class MainActivity : AudioServiceFragmentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent) setIntent(intent)
RecentAppTasks.removeDuplicates(this)
if (intent.hasExtra(CallConst.EXTRA_CALL)) { if (intent.hasExtra(CallConst.EXTRA_CALL)) {
applyCallWindowFlags() applyCallWindowFlags()
stashCall(intent, emit = true) stashCall(intent, emit = true)
@@ -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")
}
}
}
}
@@ -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",
)
}
}
@@ -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))
}
}
+21 -9
View File
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -84,11 +85,17 @@ class DeviceContactsService {
return _readBook(); return _readBook();
} }
static Future<bool> reload() async { static Future<bool> reload() => ensureLoadedInteractive(force: true);
@visibleForTesting
static Future<bool> readBookForTesting() => _readBook();
@visibleForTesting
static void resetMemoryForTesting() {
_loaded = false; _loaded = false;
_byLast10.clear(); _byLast10.clear();
_phonebook.clear(); _phonebook.clear();
return ensureLoadedInteractive(force: true); _readInFlight = null;
} }
static Future<void> _forgetDenial() async { static Future<void> _forgetDenial() async {
@@ -100,30 +107,35 @@ class DeviceContactsService {
.whenComplete(() => _readInFlight = null); .whenComplete(() => _readInFlight = null);
static Future<bool> _readBookOnce() async { static Future<bool> _readBookOnce() async {
_loaded = false;
_byLast10.clear();
_phonebook.clear();
try { try {
FlutterContacts.config.includeNonVisibleOnAndroid = true; FlutterContacts.config.includeNonVisibleOnAndroid = true;
final contacts = await FlutterContacts.getContacts(withProperties: true); final contacts = await FlutterContacts.getContacts(withProperties: true);
_byLast10.clear(); final nextByLast10 = <String, String>{};
_phonebook.clear(); final nextPhonebook = <String, String>{};
for (final contact in contacts) { for (final contact in contacts) {
final name = contact.displayName.trim(); final name = contact.displayName.trim();
for (final phone in contact.phones) { for (final phone in contact.phones) {
final normalized = normalizePhone(phone.number); final normalized = normalizePhone(phone.number);
if (normalized != null) { if (normalized != null) {
_phonebook.putIfAbsent( nextPhonebook.putIfAbsent(
normalized, normalized,
() => name.isEmpty ? normalized : name, () => name.isEmpty ? normalized : name,
); );
} }
final key = _last10(phone.number); final key = _last10(phone.number);
if (key != null) { 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; _loaded = true;
logger.i( logger.i(
'Телефонная книга: прочитано ${contacts.length} записей, ' 'Телефонная книга: прочитано ${contacts.length} записей, '
+10 -7
View File
@@ -3,7 +3,6 @@ import 'dart:io';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:open_filex/open_filex.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -70,14 +69,18 @@ abstract class UpdateInstaller {
); );
} }
final opened = await OpenFilex.open( try {
file.path, final launched = await _integrityChannel.invokeMethod<bool>(
type: 'application/vnd.android.package-archive', 'launchApkInstaller',
); {'path': file.path},
if (opened.type != ResultType.done) { );
if (launched != true) {
return const UpdateInstallResult(UpdateInstallStatus.installFailed);
}
} on PlatformException catch (error) {
return UpdateInstallResult( return UpdateInstallResult(
UpdateInstallStatus.installFailed, UpdateInstallStatus.installFailed,
error: opened.message, error: error.message,
); );
} }
return const UpdateInstallResult(UpdateInstallStatus.done); return const UpdateInstallResult(UpdateInstallStatus.done);
+1 -1
View File
@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.10.4 sdk: ^3.10.4
@@ -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<Map<String, dynamic>> book(String name) => [
Contact(id: '7', displayName: name, phones: [Phone('+79000000007')]).toJson(),
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Future<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>>();
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), 'Серверное имя');
});
}