[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:flutter/foundation.dart';
import 'package:installed_apps/app_info.dart' as installed; import 'package:installed_apps/app_info.dart' as installed;
import 'package:installed_apps/installed_apps.dart'; import 'package:installed_apps/installed_apps.dart';
@@ -77,6 +80,9 @@ class AppProvider extends ChangeNotifier {
// Favorites state // Favorites state
Set<String> _favoritePackages = {}; Set<String> _favoritePackages = {};
// Cached device ABI list
List<String>? _supportedAbis;
// Getters // Getters
List<FDroidApp> get latestApps => _latestApps; List<FDroidApp> get latestApps => _latestApps;
LoadingState get latestAppsState => _latestAppsState; LoadingState get latestAppsState => _latestAppsState;
@@ -673,7 +679,7 @@ class AppProvider extends ChangeNotifier {
final includeUnstable = await _preferencesService.getIncludeUnstable( final includeUnstable = await _preferencesService.getIncludeUnstable(
app.packageName, app.packageName,
); );
return app.getLatestVersion(includeUnstable: includeUnstable); return _selectBestVersionForDevice(app, includeUnstable: includeUnstable);
} }
/// Gets whether unstable versions should be included for a specific app /// Gets whether unstable versions should be included for a specific app
@@ -688,6 +694,87 @@ class AppProvider extends ChangeNotifier {
notifyListeners(); 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 /// Attempts to launch an installed app by package name
Future<bool> openInstalledApp(String packageName) async { Future<bool> openInstalledApp(String packageName) async {
try { try {
+81 -1
View File
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:android_intent_plus/android_intent.dart'; import 'package:android_intent_plus/android_intent.dart';
import 'package:app_installer/app_installer.dart'; import 'package:app_installer/app_installer.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -96,6 +97,7 @@ class DownloadProvider extends ChangeNotifier {
final AppPreferencesService _preferencesService = AppPreferencesService(); final AppPreferencesService _preferencesService = AppPreferencesService();
final ShizukuApi _shizukuApi = ShizukuApi(); final ShizukuApi _shizukuApi = ShizukuApi();
String? _androidPackageName; String? _androidPackageName;
List<String>? _supportedAbis;
// Delay after Shizuku installation to allow UI to fetch installed apps // Delay after Shizuku installation to allow UI to fetch installed apps
static const Duration _shizukuInstallSettleDelay = Duration(seconds: 2); static const Duration _shizukuInstallSettleDelay = Duration(seconds: 2);
@@ -170,9 +172,87 @@ class DownloadProvider extends ChangeNotifier {
return false; 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 /// Downloads an APK file
Future<String?> downloadApk(FDroidApp app, {bool? skipAutoInstall}) async { Future<String?> downloadApk(FDroidApp app, {bool? skipAutoInstall}) async {
final version = app.latestVersion; final version = await _selectBestVersionForDevice(app);
skipAutoInstall = _settingsProvider.autoInstallApk; skipAutoInstall = _settingsProvider.autoInstallApk;
if (version == null) { if (version == null) {
throw Exception('No version available for download'); throw Exception('No version available for download');
+236 -154
View File
@@ -2472,21 +2472,43 @@ class _AllVersionsSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<AppProvider>( return Consumer<AppProvider>(
builder: (context, appProvider, _) { builder: (context, appProvider, _) {
return FutureBuilder<bool>( return FutureBuilder<List<Object?>>(
future: appProvider.getIncludeUnstable(app.packageName), future: Future.wait([
appProvider.getIncludeUnstable(app.packageName),
appProvider.getSupportedAbis(),
]),
builder: (context, snapshot) { 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() ?? []; var versions = app.packages?.values.toList() ?? [];
if (versions.isEmpty) return const SizedBox.shrink(); if (versions.isEmpty) return const SizedBox.shrink();
// Filter out unstable versions if not enabled
if (!includeUnstable) { if (!includeUnstable) {
versions = versions.where((v) => !v.isUnstable).toList(); versions = versions.where((v) => !v.isUnstable).toList();
if (versions.isEmpty) return const SizedBox.shrink(); 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)); versions.sort((a, b) => b.versionCode.compareTo(a.versionCode));
return Column( return Column(
@@ -2500,6 +2522,7 @@ class _AllVersionsSection extends StatelessWidget {
children: [ children: [
...versions.map((version) { ...versions.map((version) {
final isLatest = version == versions.first; final isLatest = version == versions.first;
final compatibleAbi = supportsDevice(version);
return Container( return Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -2555,30 +2578,55 @@ class _AllVersionsSection extends StatelessWidget {
], ],
), ),
), ),
if (isLatest) Column(
Container( crossAxisAlignment: CrossAxisAlignment.end,
padding: const EdgeInsets.symmetric( children: [
horizontal: 8, if (isLatest)
vertical: 4, Container(
), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: 8,
color: Theme.of( vertical: 4,
context, ),
).colorScheme.primary, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4), color: Theme.of(
), context,
child: Text( ).colorScheme.primary,
'Latest', borderRadius: BorderRadius.circular(
style: Theme.of(context) 4,
.textTheme
.labelSmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onPrimary,
), ),
), ),
), 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), const SizedBox(height: 8),
@@ -2657,6 +2705,8 @@ class _VersionDownloadButton extends StatelessWidget {
version.versionName, version.versionName,
); );
final supportedAbisFuture = appProvider.getSupportedAbis();
final isInstalledVersion = isInstalled && installedApp != null final isInstalledVersion = isInstalled && installedApp != null
? (installedApp.versionCode != null ? (installedApp.versionCode != null
? installedApp.versionCode == version.versionCode ? installedApp.versionCode == version.versionCode
@@ -2694,159 +2744,191 @@ class _VersionDownloadButton extends StatelessWidget {
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () async { onPressed: () async {
try { try {
final opened = await appProvider.openInstalledApp( final appWithVersion = app.copyWithVersion(version);
app.packageName, await downloadProvider.downloadApk(appWithVersion);
); if (context.mounted) {
if (!opened && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to open ${app.name}')), SnackBar(
content: Text(
'Downloading ${version.versionName}...',
),
),
); );
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(content: Text('Download failed: $e')),
).showSnackBar(SnackBar(content: Text('Error: $e'))); );
} }
} }
}, },
icon: const Icon(Symbols.open_in_new_rounded, size: 18), icon: const Icon(Symbols.download, size: 18),
label: const Text('Open'), label: const Text('Download'),
), ),
), ),
], ],
); );
} }
if (isDownloading) { return FutureBuilder<List<String>>(
return Column( future: supportedAbisFuture,
crossAxisAlignment: CrossAxisAlignment.start, builder: (context, snapshot) {
children: [ final supportedAbis = snapshot.data ?? const <String>[];
Row( final native = version.nativecode ?? const <String>[];
mainAxisAlignment: MainAxisAlignment.spaceBetween, 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: [ children: [
Text( Text(
'Downloading... ${(progress * 100).toInt()}%', 'Installing...',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, 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( OutlinedButton.icon(
onPressed: () { onPressed: () async {
downloadProvider.cancelDownload( try {
app.packageName, await downloadProvider.deleteDownloadedFile(
version.versionName, 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), icon: const Icon(Symbols.delete_rounded, size: 18),
label: const Text('Cancel'), label: const Text('Delete'),
), ),
], ],
), );
const SizedBox(height: 8), }
LinearProgressIndicator(value: progress),
],
);
}
if (isInstalling) { return Row(
return Column( spacing: 8,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Expanded(
Text( child: FilledButton.icon(
'Installing...', onPressed: () async {
style: Theme.of(context).textTheme.bodySmall?.copyWith( try {
color: Theme.of(context).colorScheme.onSurfaceVariant, 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'),
),
), ),
), FilledButton.tonalIcon(
const SizedBox(height: 8), onPressed: () {
const LinearProgressIndicator(), 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'),
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')));
}
}
}, },
icon: const Icon(Symbols.download, size: 18),
label: const Text('Download'),
); );
}, },
); );
+24
View File
@@ -257,6 +257,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" 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: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1234,6 +1250,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.15.0" 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: workmanager:
dependency: "direct main" dependency: "direct main"
description: description:
+3
View File
@@ -52,6 +52,9 @@ dependencies:
# Package info for app version checking # Package info for app version checking
package_info_plus: ^9.0.0 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 for app icons
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1