[feat]: Select APKs by device ABI compatibility

This commit is contained in:
nahnah
2026-02-14 12:48:36 +00:00
parent 865493e262
commit f90b07c5c7
5 changed files with 432 additions and 156 deletions
+88 -1
View File
@@ -1,3 +1,6 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:installed_apps/app_info.dart' as installed;
import 'package:installed_apps/installed_apps.dart';
@@ -77,6 +80,9 @@ class AppProvider extends ChangeNotifier {
// Favorites state
Set<String> _favoritePackages = {};
// Cached device ABI list
List<String>? _supportedAbis;
// Getters
List<FDroidApp> get latestApps => _latestApps;
LoadingState get latestAppsState => _latestAppsState;
@@ -673,7 +679,7 @@ class AppProvider extends ChangeNotifier {
final includeUnstable = await _preferencesService.getIncludeUnstable(
app.packageName,
);
return app.getLatestVersion(includeUnstable: includeUnstable);
return _selectBestVersionForDevice(app, includeUnstable: includeUnstable);
}
/// Gets whether unstable versions should be included for a specific app
@@ -688,6 +694,87 @@ class AppProvider extends ChangeNotifier {
notifyListeners();
}
Future<List<String>> _getSupportedAbis() async {
if (_supportedAbis != null) return _supportedAbis!;
if (!Platform.isAndroid) {
_supportedAbis = const [];
return _supportedAbis!;
}
try {
final info = await DeviceInfoPlugin().androidInfo;
final rawAbis =
info.supportedAbis ??
info.supported64BitAbis ??
info.supported32BitAbis ??
const <String>[];
final abis = rawAbis.where((abi) => abi.isNotEmpty).toList();
if (abis.isNotEmpty) {
_supportedAbis = abis;
return abis;
}
} catch (e) {
debugPrint('[AppProvider] Failed to read supported ABIs: $e');
}
_supportedAbis = const [];
return _supportedAbis!;
}
Future<List<String>> getSupportedAbis() => _getSupportedAbis();
Future<FDroidVersion?> _selectBestVersionForDevice(
FDroidApp app, {
required bool includeUnstable,
}) async {
if (app.packages == null || app.packages!.isEmpty) return null;
var versions = app.packages!.values.toList();
if (!includeUnstable) {
versions = versions.where((v) => !v.isUnstable).toList();
if (versions.isEmpty) return null;
}
final abis = await _getSupportedAbis();
bool isUniversal(FDroidVersion v) =>
v.nativecode == null || v.nativecode!.isEmpty;
bool supportsDevice(FDroidVersion v) {
if (isUniversal(v)) return true;
if (abis.isEmpty) return true;
return v.nativecode!.any((abi) => abis.contains(abi));
}
final compatible = versions.where(supportsDevice).toList();
final candidates = compatible.isNotEmpty ? compatible : versions;
int abiRank(FDroidVersion v) {
if (isUniversal(v)) return abis.length + 1;
final matches = v.nativecode!
.map((abi) => abis.indexOf(abi))
.where((idx) => idx >= 0)
.toList();
return matches.isEmpty
? abis.length + 2
: matches.reduce((a, b) => a < b ? a : b);
}
candidates.sort((a, b) {
final versionCompare = b.versionCode.compareTo(a.versionCode);
if (versionCompare != 0) return versionCompare;
if (abis.isEmpty) return 0;
return abiRank(a).compareTo(abiRank(b));
});
final chosen = candidates.first;
debugPrint(
'[AppProvider] ABI selection for ${app.packageName}: chosen ${chosen.versionName} (${chosen.apkName}), deviceAbis=$abis, native=${chosen.nativecode}',
);
return chosen;
}
/// Attempts to launch an installed app by package name
Future<bool> openInstalledApp(String packageName) async {
try {
+81 -1
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:android_intent_plus/android_intent.dart';
import 'package:app_installer/app_installer.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
@@ -96,6 +97,7 @@ class DownloadProvider extends ChangeNotifier {
final AppPreferencesService _preferencesService = AppPreferencesService();
final ShizukuApi _shizukuApi = ShizukuApi();
String? _androidPackageName;
List<String>? _supportedAbis;
// Delay after Shizuku installation to allow UI to fetch installed apps
static const Duration _shizukuInstallSettleDelay = Duration(seconds: 2);
@@ -170,9 +172,87 @@ class DownloadProvider extends ChangeNotifier {
return false;
}
Future<List<String>> _getSupportedAbis() async {
if (_supportedAbis != null) {
return _supportedAbis!;
}
if (!Platform.isAndroid) {
_supportedAbis = const [];
return _supportedAbis!;
}
try {
final info = await DeviceInfoPlugin().androidInfo;
final rawAbis =
info.supportedAbis ??
info.supported64BitAbis ??
info.supported32BitAbis ??
const <String>[];
final abis = rawAbis.where((abi) => abi.isNotEmpty).toList();
if (abis.isNotEmpty) {
_supportedAbis = abis;
return abis;
}
} catch (e) {
debugPrint('[DownloadProvider] Failed to read supported ABIs: $e');
}
_supportedAbis = const [];
return _supportedAbis!;
}
Future<FDroidVersion?> _selectBestVersionForDevice(FDroidApp app) async {
final versions = app.packages?.values.toList() ?? [];
if (versions.isEmpty) return null;
final abis = await _getSupportedAbis();
bool isUniversal(FDroidVersion v) =>
v.nativecode == null || v.nativecode!.isEmpty;
bool supportsDevice(FDroidVersion v) {
if (isUniversal(v)) return true;
if (abis.isEmpty) return true;
return v.nativecode!.any((abi) => abis.contains(abi));
}
final compatible = versions.where(supportsDevice).toList();
if (compatible.isEmpty) {
debugPrint(
'[DownloadProvider] No ABI-specific match found, using latest version',
);
return app.getLatestVersion();
}
int abiRank(FDroidVersion v) {
if (isUniversal(v)) return abis.length + 1;
final matches = v.nativecode!
.map((abi) => abis.indexOf(abi))
.where((idx) => idx >= 0)
.toList();
return matches.isEmpty
? abis.length + 2
: matches.reduce((a, b) => a < b ? a : b);
}
compatible.sort((a, b) {
final versionCompare = b.versionCode.compareTo(a.versionCode);
if (versionCompare != 0) return versionCompare;
if (abis.isEmpty) return 0;
return abiRank(a).compareTo(abiRank(b));
});
final chosen = compatible.first;
debugPrint(
'[DownloadProvider] ABI selection for ${app.packageName}: chosen ${chosen.versionName} (${chosen.apkName}), deviceAbis=$abis, native=${chosen.nativecode}',
);
return chosen;
}
/// Downloads an APK file
Future<String?> downloadApk(FDroidApp app, {bool? skipAutoInstall}) async {
final version = app.latestVersion;
final version = await _selectBestVersionForDevice(app);
skipAutoInstall = _settingsProvider.autoInstallApk;
if (version == null) {
throw Exception('No version available for download');
+236 -154
View File
@@ -2472,21 +2472,43 @@ class _AllVersionsSection extends StatelessWidget {
Widget build(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, appProvider, _) {
return FutureBuilder<bool>(
future: appProvider.getIncludeUnstable(app.packageName),
return FutureBuilder<List<Object?>>(
future: Future.wait([
appProvider.getIncludeUnstable(app.packageName),
appProvider.getSupportedAbis(),
]),
builder: (context, snapshot) {
final includeUnstable = snapshot.data ?? false;
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox.shrink();
}
final includeUnstable = (snapshot.data?[0] as bool?) ?? false;
final supportedAbis =
(snapshot.data?[1] as List<String>?) ?? const <String>[];
var versions = app.packages?.values.toList() ?? [];
if (versions.isEmpty) return const SizedBox.shrink();
// Filter out unstable versions if not enabled
if (!includeUnstable) {
versions = versions.where((v) => !v.isUnstable).toList();
if (versions.isEmpty) return const SizedBox.shrink();
}
// Sort versions by version code descending
bool isUniversal(FDroidVersion v) =>
v.nativecode == null || v.nativecode!.isEmpty;
bool supportsDevice(FDroidVersion v) {
if (isUniversal(v)) return true;
if (supportedAbis.isEmpty) return true;
return v.nativecode!.any((abi) => supportedAbis.contains(abi));
}
// Filter out incompatible ABIs; if none remain, fall back to show all
final compatible = versions.where(supportsDevice).toList();
if (compatible.isNotEmpty) {
versions = compatible;
}
versions.sort((a, b) => b.versionCode.compareTo(a.versionCode));
return Column(
@@ -2500,6 +2522,7 @@ class _AllVersionsSection extends StatelessWidget {
children: [
...versions.map((version) {
final isLatest = version == versions.first;
final compatibleAbi = supportsDevice(version);
return Container(
padding: const EdgeInsets.all(12),
@@ -2555,30 +2578,55 @@ class _AllVersionsSection extends StatelessWidget {
],
),
),
if (isLatest)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Latest',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onPrimary,
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isLatest)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary,
borderRadius: BorderRadius.circular(
4,
),
),
),
),
child: Text(
'Latest',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
if (!compatibleAbi)
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Text(
'Incompatible ABI',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.error,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
const SizedBox(height: 8),
@@ -2657,6 +2705,8 @@ class _VersionDownloadButton extends StatelessWidget {
version.versionName,
);
final supportedAbisFuture = appProvider.getSupportedAbis();
final isInstalledVersion = isInstalled && installedApp != null
? (installedApp.versionCode != null
? installedApp.versionCode == version.versionCode
@@ -2694,159 +2744,191 @@ class _VersionDownloadButton extends StatelessWidget {
child: FilledButton.icon(
onPressed: () async {
try {
final opened = await appProvider.openInstalledApp(
app.packageName,
);
if (!opened && context.mounted) {
final appWithVersion = app.copyWithVersion(version);
await downloadProvider.downloadApk(appWithVersion);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to open ${app.name}')),
SnackBar(
content: Text(
'Downloading ${version.versionName}...',
),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download failed: $e')),
);
}
}
},
icon: const Icon(Symbols.open_in_new_rounded, size: 18),
label: const Text('Open'),
icon: const Icon(Symbols.download, size: 18),
label: const Text('Download'),
),
),
],
);
}
if (isDownloading) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
return FutureBuilder<List<String>>(
future: supportedAbisFuture,
builder: (context, snapshot) {
final supportedAbis = snapshot.data ?? const <String>[];
final native = version.nativecode ?? const <String>[];
final isUniversal = native.isEmpty;
final isAbiCompatible =
isUniversal ||
supportedAbis.isEmpty ||
native.any((abi) => supportedAbis.contains(abi));
if (!isAbiCompatible) {
return OutlinedButton.icon(
onPressed: null,
icon: const Icon(Symbols.block, size: 18),
label: Text(
native.isEmpty
? 'Incompatible APK'
: 'Incompatible (${native.join(', ')})',
),
);
}
if (isDownloading) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Downloading... ${(progress * 100).toInt()}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
OutlinedButton.icon(
onPressed: () {
downloadProvider.cancelDownload(
app.packageName,
version.versionName,
);
},
icon: const Icon(Symbols.close, size: 18),
label: const Text('Cancel'),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(value: progress),
],
);
}
if (isInstalling) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Downloading... ${(progress * 100).toInt()}%',
'Installing...',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
);
}
if (isDownloaded) {
return Row(
spacing: 8,
children: [
Expanded(
child: FilledButton.icon(
onPressed: () async {
try {
await downloadProvider.installApk(
downloadInfo.filePath!,
app.packageName,
version.versionName,
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Install failed: $e')),
);
}
}
},
icon: const Icon(
Symbols.install_mobile_rounded,
size: 18,
),
label: const Text('Install'),
),
),
OutlinedButton.icon(
onPressed: () {
downloadProvider.cancelDownload(
app.packageName,
version.versionName,
);
onPressed: () async {
try {
await downloadProvider.deleteDownloadedFile(
downloadInfo.filePath!,
);
downloadProvider.removeDownload(
app.packageName,
version.versionName,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('APK deleted')),
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Delete failed: $e')),
);
}
}
},
icon: const Icon(Symbols.close, size: 18),
label: const Text('Cancel'),
icon: const Icon(Symbols.delete_rounded, size: 18),
label: const Text('Delete'),
),
],
),
const SizedBox(height: 8),
LinearProgressIndicator(value: progress),
],
);
}
);
}
if (isInstalling) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Installing...',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
return Row(
spacing: 8,
children: [
Expanded(
child: FilledButton.icon(
onPressed: () async {
try {
final appWithVersion = app.copyWithVersion(version);
await downloadProvider.downloadApk(appWithVersion);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download failed: $e')),
);
}
}
},
icon: const Icon(Symbols.download_rounded, size: 18),
label: const Text('Download'),
),
),
),
const SizedBox(height: 8),
const LinearProgressIndicator(),
],
);
}
if (isDownloaded) {
return FilledButton.icon(
onPressed: () async {
final settings = context.read<SettingsProvider>();
final hasPermission = await downloadProvider
.requestInstallPermission();
if (!hasPermission) {
if (settings.installMethod == InstallMethod.shizuku) {
await _handleShizukuUnavailable(context, settings);
return;
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Install permission is required'),
),
);
}
return;
}
try {
if (downloadInfo.filePath != null) {
await downloadProvider.installApk(
downloadInfo.filePath!,
app.packageName,
version.versionName,
);
await appProvider.waitForInstalled(app.packageName);
await appProvider.fetchInstalledApps();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Installing ${app.name}...')),
);
}
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Installation failed: $e')),
);
}
}
},
icon: const Icon(Symbols.install_mobile, size: 18),
label: const Text('Install'),
);
}
return FilledButton.tonalIcon(
onPressed: () async {
final hasPermission = await downloadProvider.requestPermissions();
if (!hasPermission) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Storage permission is required'),
),
);
}
return;
}
try {
await downloadProvider.downloadApk(app.copyWithVersion(version));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Downloading ${version.versionName}...'),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
}
}
FilledButton.tonalIcon(
onPressed: () {
final url = version.downloadUrl(app.repositoryUrl);
launchUrl(Uri.parse(url));
},
icon: const Icon(Symbols.open_in_new_rounded, size: 18),
label: const Text('Open link'),
),
],
);
},
icon: const Icon(Symbols.download, size: 18),
label: const Text('Download'),
);
},
);
+24
View File
@@ -257,6 +257,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
url: "https://pub.dev"
source: hosted
version: "11.5.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
dio:
dependency: "direct main"
description:
@@ -1234,6 +1250,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
workmanager:
dependency: "direct main"
description:
+3
View File
@@ -52,6 +52,9 @@ dependencies:
# Package info for app version checking
package_info_plus: ^9.0.0
# Device info for ABI detection
device_info_plus: ^11.1.0
# Cached network image for app icons
cached_network_image: ^3.3.1