diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 655aee5..9a49062 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -74,6 +74,15 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> + + + _downloads = {}; final NotificationService _notificationService = NotificationService(); - final InstallationTrackingService _trackingService = InstallationTrackingService(); + final InstallationTrackingService _trackingService = + InstallationTrackingService(); final AppPreferencesService _preferencesService = AppPreferencesService(); + final ShizukuApi _shizukuApi = ShizukuApi(); + String? _androidPackageName; DownloadProvider(this._apiService, this._settingsProvider) { _initNotifications(); @@ -393,15 +400,136 @@ class DownloadProvider extends ChangeNotifier { throw Exception('APK file missing or empty'); } - await AppInstaller.installApk(filePath); + if (_settingsProvider.installMethod == InstallMethod.shizuku) { + await _installWithShizuku(filePath); + return; + } + + await _installWithSystemInstaller(filePath); } catch (e) { throw Exception('Failed to install APK: $e'); } } + Future _installWithSystemInstaller(String filePath) async { + await AppInstaller.installApk(filePath); + } + + Future _installWithShizuku(String filePath) async { + if (!Platform.isAndroid) { + throw Exception('Shizuku install is only available on Android'); + } + + final isBinderRunning = await _shizukuApi.pingBinder() ?? false; + if (!isBinderRunning) { + throw Exception('Shizuku is not running'); + } + + var hasPermission = await _shizukuApi.checkPermission() ?? false; + if (!hasPermission) { + hasPermission = await _shizukuApi.requestPermission() ?? false; + } + if (!hasPermission) { + throw Exception('Shizuku permission denied'); + } + + final packageName = await _getAndroidPackageName(); + final sourcePath = await _prepareShizukuSource(filePath); + final escapedPath = _escapeForDoubleQuotes(sourcePath); + final fileName = Uri.file(sourcePath).pathSegments.last; + final tempPath = '/data/local/tmp/$fileName'; + + try { + // Copy into /data/local/tmp so system_server can read it. + final copyCommand = _buildShizukuCopyCommand( + packageName: packageName, + sourcePath: escapedPath, + destPath: tempPath, + ); + final copyResult = await _shizukuApi.runCommand(copyCommand); + if (copyResult == null) { + throw Exception('Shizuku copy returned no response'); + } + final copyLower = copyResult.toLowerCase(); + if (copyLower.contains('permission denied') || + copyLower.contains('no such file') || + copyLower.contains('error')) { + throw Exception('Shizuku copy failed: $copyResult'); + } + + final installCommand = 'pm install -r -g "$tempPath"'; + final result = await _shizukuApi.runCommand(installCommand); + if (result == null) { + throw Exception('Shizuku install returned no response'); + } + + final normalized = result.toLowerCase(); + final success = normalized.contains('success'); + if (!success) { + throw Exception('Shizuku install failed: $result'); + } + } finally { + await _shizukuApi.runCommand('rm -f "$tempPath"'); + } + } + + Future _prepareShizukuSource(String filePath) async { + final tempDir = await getTemporaryDirectory(); + final fileName = Uri.file(filePath).pathSegments.last; + final stagedPath = p.join(tempDir.path, fileName); + if (filePath == stagedPath) { + return stagedPath; + } + + final sourceFile = File(filePath); + final stagedFile = File(stagedPath); + await stagedFile.parent.create(recursive: true); + await sourceFile.copy(stagedPath); + return stagedPath; + } + + String _buildShizukuCopyCommand({ + required String packageName, + required String sourcePath, + required String destPath, + }) { + final isInternal = + sourcePath.startsWith('/data/user/0/$packageName/') || + sourcePath.startsWith('/data/data/$packageName/'); + if (isInternal) { + return 'sh -c "run-as $packageName cat \\"$sourcePath\\" > \\"$destPath\\""'; + } + return 'cp "$sourcePath" "$destPath"'; + } + + Future _getAndroidPackageName() async { + if (_androidPackageName != null) { + return _androidPackageName!; + } + final info = await PackageInfo.fromPlatform(); + final packageName = info.packageName; + _androidPackageName = packageName; + return packageName; + } + + String _escapeForDoubleQuotes(String value) { + return value.replaceAll('\\', '\\\\').replaceAll('"', '\\"'); + } + /// Requests install permission Future requestInstallPermission() async { try { + if (_settingsProvider.installMethod == InstallMethod.shizuku) { + final isBinderRunning = await _shizukuApi.pingBinder() ?? false; + if (!isBinderRunning) { + return false; + } + final hasPermission = await _shizukuApi.checkPermission() ?? false; + if (hasPermission) { + return true; + } + return await _shizukuApi.requestPermission() ?? false; + } final status = await Permission.requestInstallPackages.request(); return status.isGranted; } catch (e) { @@ -432,7 +560,7 @@ class DownloadProvider extends ChangeNotifier { data: 'package:$packageName', ); await intent.launch(); - + // Remove tracking data when app is uninstalled // Note: This is best-effort. The actual uninstall is handled by Android // and we can't know for sure if it succeeded immediately diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 10b0c8a..5d76bf2 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -5,11 +5,14 @@ enum ThemeStyle { material, florid } enum UpdateNetworkPolicy { any, wifiOnly, wifiAndCharging } +enum InstallMethod { system, shizuku } + class SettingsProvider extends ChangeNotifier { static const _themeModeKey = 'theme_mode'; static const _themeStyleKey = 'theme_style'; static const _autoInstallKey = 'auto_install_apk'; static const _autoDeleteKey = 'auto_delete_apk'; + static const _installMethodKey = 'install_method'; static const _localeKey = 'locale'; static const _onboardingCompleteKey = 'onboarding_complete'; static const _sniBypassKey = 'sni_bypass_enabled'; @@ -21,6 +24,7 @@ class SettingsProvider extends ChangeNotifier { ThemeStyle _themeStyle = ThemeStyle.florid; bool _autoInstallApk = true; bool _autoDeleteApk = true; + InstallMethod _installMethod = InstallMethod.system; String _locale = 'en-US'; bool _onboardingComplete = false; bool _sniBypassEnabled = true; @@ -38,6 +42,7 @@ class SettingsProvider extends ChangeNotifier { ThemeStyle get themeStyle => _themeStyle; bool get autoInstallApk => _autoInstallApk; bool get autoDeleteApk => _autoDeleteApk; + InstallMethod get installMethod => _installMethod; String get locale => _locale; bool get onboardingComplete => _onboardingComplete; bool get sniBypassEnabled => _sniBypassEnabled; @@ -105,6 +110,12 @@ class SettingsProvider extends ChangeNotifier { } _autoInstallApk = prefs.getBool(_autoInstallKey) ?? true; _autoDeleteApk = prefs.getBool(_autoDeleteKey) ?? true; + final installMethodIndex = prefs.getInt(_installMethodKey); + if (installMethodIndex != null && + installMethodIndex >= 0 && + installMethodIndex < InstallMethod.values.length) { + _installMethod = InstallMethod.values[installMethodIndex]; + } _locale = prefs.getString(_localeKey) ?? 'en-US'; _onboardingComplete = prefs.getBool(_onboardingCompleteKey) ?? false; _sniBypassEnabled = prefs.getBool(_sniBypassKey) ?? true; @@ -147,6 +158,13 @@ class SettingsProvider extends ChangeNotifier { await prefs.setBool(_autoDeleteKey, value); } + Future setInstallMethod(InstallMethod method) async { + _installMethod = method; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_installMethodKey, method.index); + } + Future setLocale(String locale) async { if (!availableLocales.contains(locale)) { throw ArgumentError('Unsupported locale: $locale'); diff --git a/lib/screens/app_details_screen.dart b/lib/screens/app_details_screen.dart index d96d9dd..5717877 100644 --- a/lib/screens/app_details_screen.dart +++ b/lib/screens/app_details_screen.dart @@ -186,6 +186,7 @@ class _AppDetailsScreenState extends State { } } catch (e) { if (context.mounted) { + print('Installation failed: $e'); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Installation failed: ${e.toString()}')), ); diff --git a/lib/screens/troubleshooting_screen.dart b/lib/screens/troubleshooting_screen.dart index 102a01e..d5e882e 100644 --- a/lib/screens/troubleshooting_screen.dart +++ b/lib/screens/troubleshooting_screen.dart @@ -10,6 +10,55 @@ import 'package:provider/provider.dart'; class TroubleshootingScreen extends StatelessWidget { const TroubleshootingScreen({super.key}); + String _installMethodLabel(InstallMethod method) { + switch (method) { + case InstallMethod.shizuku: + return 'Shizuku'; + case InstallMethod.system: + default: + return 'System installer'; + } + } + + Future _showInstallMethodDialog( + BuildContext context, + SettingsProvider settings, + ) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Installation method'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: InstallMethod.values + .map( + (method) => RadioListTile( + value: method, + groupValue: settings.installMethod, + title: Text(_installMethodLabel(method)), + subtitle: method == InstallMethod.shizuku + ? const Text('Requires Shizuku to be running') + : const Text('Uses the standard system installer'), + onChanged: (value) async { + if (value == null) return; + await settings.setInstallMethod(value); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + ), + ) + .toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ), + ); + } + Future _clearRepoCache(BuildContext context) async { final api = context.read(); await api.clearRepositoryCache(); @@ -62,6 +111,15 @@ class TroubleshootingScreen extends StatelessWidget { MListHeader(title: 'Downloads & Storage'), MListView( items: [ + MListItemData( + title: 'Installation method', + subtitle: _installMethodLabel( + settings.installMethod, + ), + onTap: () => + _showInstallMethodDialog(context, settings), + suffix: const Icon(Symbols.chevron_right), + ), MListItemData( title: 'Auto-install after download', onTap: () { diff --git a/lib/widgets/app_list_item.dart b/lib/widgets/app_list_item.dart index 91b6760..4a9e00a 100644 --- a/lib/widgets/app_list_item.dart +++ b/lib/widgets/app_list_item.dart @@ -455,7 +455,7 @@ class _QuickViewModal extends StatelessWidget { ).showSnackBar( const SnackBar( content: Text( - 'Install permission required', + 'Install access required', ), ), ); diff --git a/pubspec.lock b/pubspec.lock index aa2e7a1..7cb1220 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -941,6 +941,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shizuku_api: + dependency: "direct main" + description: + name: shizuku_api + sha256: "0d77e9dd4aac6b2c1a5fa3af47f3d4489e2606e08864c7b55d0c7cf0bac39fd1" + url: "https://pub.dev" + source: hosted + version: "1.2.2" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 3574004..65f255b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,9 @@ dependencies: # App installer for APK installation app_installer: ^1.1.0 + # Shizuku-based installer + shizuku_api: 1.2.2 + # Share plus for sharing apps share_plus: ^12.0.1