From f90b07c5c733d13ece20f80638b996713fabbca1 Mon Sep 17 00:00:00 2001 From: nahnah Date: Sat, 14 Feb 2026 12:48:36 +0000 Subject: [PATCH] [feat]: Select APKs by device ABI compatibility --- lib/providers/app_provider.dart | 89 +++++- lib/providers/download_provider.dart | 82 +++++- lib/screens/app_details_screen.dart | 390 ++++++++++++++++----------- pubspec.lock | 24 ++ pubspec.yaml | 3 + 5 files changed, 432 insertions(+), 156 deletions(-) diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index f832548..802046b 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -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 _favoritePackages = {}; + // Cached device ABI list + List? _supportedAbis; + // Getters List 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> _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 []; + 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> getSupportedAbis() => _getSupportedAbis(); + + Future _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 openInstalledApp(String packageName) async { try { diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 8a4fc63..d0b8569 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -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? _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> _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 []; + 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 _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 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'); diff --git a/lib/screens/app_details_screen.dart b/lib/screens/app_details_screen.dart index 806031f..bf8f104 100644 --- a/lib/screens/app_details_screen.dart +++ b/lib/screens/app_details_screen.dart @@ -2472,21 +2472,43 @@ class _AllVersionsSection extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, appProvider, _) { - return FutureBuilder( - future: appProvider.getIncludeUnstable(app.packageName), + return FutureBuilder>( + 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?) ?? const []; 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>( + future: supportedAbisFuture, + builder: (context, snapshot) { + final supportedAbis = snapshot.data ?? const []; + final native = version.nativecode ?? const []; + 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(); - 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'), ); }, ); diff --git a/pubspec.lock b/pubspec.lock index da23d8d..95df91f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index fda128b..d9d0f02 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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