[feat]: Implement Shizuku Installation method #34
Very Experimental! Installing works
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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()}')),
|
||||
);
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -455,7 +455,7 @@ class _QuickViewModal extends StatelessWidget {
|
||||
).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Install permission required',
|
||||
'Install access required',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user