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