[feat]: Implement Shizuku Installation method #34

Very Experimental! Installing works
This commit is contained in:
nahnah
2026-02-11 20:46:02 +00:00
parent fabe1ccd7d
commit 760cd9d7fd
8 changed files with 229 additions and 4 deletions
+9
View File
@@ -74,6 +74,15 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Shizuku -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
android:authorities="${applicationId}.shizuku"
android:multiprocess="false"
android:enabled="true"
android:exported="true"
android:permission="android.permission.INTERACT_ACROSS_USERS_FULL" />
<!-- Notification broadcast receivers for flutter_local_notifications -->
<receiver
+131 -3
View File
@@ -3,7 +3,11 @@ import 'dart:io';
import 'package:android_intent_plus/android_intent.dart';
import 'package:app_installer/app_installer.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:shizuku_api/shizuku_api.dart';
import '../models/fdroid_app.dart';
import '../providers/settings_provider.dart';
@@ -80,8 +84,11 @@ class DownloadProvider extends ChangeNotifier {
SettingsProvider _settingsProvider;
final Map<String, DownloadInfo> _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<void> _installWithSystemInstaller(String filePath) async {
await AppInstaller.installApk(filePath);
}
Future<void> _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<String> _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<String> _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<bool> 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
+18
View File
@@ -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<void> setInstallMethod(InstallMethod method) async {
_installMethod = method;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_installMethodKey, method.index);
}
Future<void> setLocale(String locale) async {
if (!availableLocales.contains(locale)) {
throw ArgumentError('Unsupported locale: $locale');
+1
View File
@@ -186,6 +186,7 @@ class _AppDetailsScreenState extends State<AppDetailsScreen> {
}
} catch (e) {
if (context.mounted) {
print('Installation failed: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Installation failed: ${e.toString()}')),
);
+58
View File
@@ -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<void> _showInstallMethodDialog(
BuildContext context,
SettingsProvider settings,
) async {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Installation method'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: InstallMethod.values
.map(
(method) => RadioListTile<InstallMethod>(
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<void> _clearRepoCache(BuildContext context) async {
final api = context.read<FDroidApiService>();
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: () {
+1 -1
View File
@@ -455,7 +455,7 @@ class _QuickViewModal extends StatelessWidget {
).showSnackBar(
const SnackBar(
content: Text(
'Install permission required',
'Install access required',
),
),
);
+8
View File
@@ -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
+3
View File
@@ -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