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
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:
@@ -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")
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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} записей, '
|
||||||
|
|||||||
@@ -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
@@ -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), 'Серверное имя');
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user