import 'dart:io'; import 'package:collection/collection.dart'; import 'package:device_apps/device_apps.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:injectable/injectable.dart'; import 'package:path_provider/path_provider.dart'; import 'package:revanced_manager/app/app.locator.dart'; import 'package:revanced_manager/gen/strings.g.dart'; import 'package:revanced_manager/models/patch.dart'; import 'package:revanced_manager/models/patched_application.dart'; import 'package:revanced_manager/services/manager_api.dart'; import 'package:revanced_manager/services/root_api.dart'; import 'package:share_plus/share_plus.dart'; @lazySingleton class PatcherAPI { static const patcherChannel = MethodChannel('app.revanced.manager.flutter/patcher'); final ManagerAPI _managerAPI = locator(); final RootAPI _rootAPI = RootAPI(); late Directory _dataDir; late Directory _tmpDir; late File _keyStoreFile; List _patches = []; List _universalPatches = []; List _compatiblePackages = []; Map filteredPatches = >{}; File? outFile; Future initialize() async { await loadPatches(); await _managerAPI.downloadIntegrations(); final Directory appCache = await getTemporaryDirectory(); _dataDir = await getExternalStorageDirectory() ?? appCache; _tmpDir = Directory('${appCache.path}/patcher'); _keyStoreFile = File('${_dataDir.path}/revanced-manager.keystore'); cleanPatcher(); } void cleanPatcher() { if (_tmpDir.existsSync()) { _tmpDir.deleteSync(recursive: true); } } List getCompatiblePackages() { final List compatiblePackages = []; for (final Patch patch in _patches) { for (final Package package in patch.compatiblePackages) { if (!compatiblePackages.contains(package.name)) { compatiblePackages.add(package.name); } } } return compatiblePackages; } List getUniversalPatches() { return _patches.where((patch) => patch.compatiblePackages.isEmpty).toList(); } Future loadPatches() async { try { if (_patches.isEmpty) { _patches = await _managerAPI.getPatches(); } } on Exception catch (e) { if (kDebugMode) { print(e); } _patches = List.empty(); } _compatiblePackages = getCompatiblePackages(); _universalPatches = getUniversalPatches(); } Future> getFilteredInstalledApps( bool showUniversalPatches, ) async { final List filteredApps = []; final bool allAppsIncluded = _universalPatches.isNotEmpty && showUniversalPatches; if (allAppsIncluded) { final appList = await DeviceApps.getInstalledApplications( includeAppIcons: true, onlyAppsWithLaunchIntent: true, ); for (final app in appList) { filteredApps.add(app as ApplicationWithIcon); } } for (final packageName in _compatiblePackages) { try { if (!filteredApps.any((app) => app.packageName == packageName)) { final ApplicationWithIcon? app = await DeviceApps.getApp( packageName, true, ) as ApplicationWithIcon?; if (app != null) { filteredApps.add(app); } } } on Exception catch (e) { if (kDebugMode) { print(e); } } } return filteredApps; } List getFilteredPatches(String packageName) { if (!_compatiblePackages.contains(packageName)) { return _universalPatches; } final List patches = _patches .where( (patch) => patch.compatiblePackages.isEmpty || !patch.name.contains('settings') && patch.compatiblePackages .any((pack) => pack.name == packageName), ) .toList(); if (!_managerAPI.areUniversalPatchesEnabled()) { filteredPatches[packageName] = patches .where((patch) => patch.compatiblePackages.isNotEmpty) .toList(); } else { filteredPatches[packageName] = patches; } return filteredPatches[packageName]; } Future> getAppliedPatches( List appliedPatches, ) async { return _patches .where((patch) => appliedPatches.contains(patch.name)) .toList(); } Future runPatcher( String packageName, String apkFilePath, List selectedPatches, ) async { final File? integrationsFile = await _managerAPI.downloadIntegrations(); final Map> options = {}; for (final patch in selectedPatches) { if (patch.options.isNotEmpty) { final Map patchOptions = {}; for (final option in patch.options) { final patchOption = _managerAPI.getPatchOption(packageName, patch.name, option.key); if (patchOption != null) { patchOptions[patchOption.key] = patchOption.value; } } options[patch.name] = patchOptions; } } if (integrationsFile != null) { _dataDir.createSync(); _tmpDir.createSync(); final Directory workDir = _tmpDir.createTempSync('tmp-'); outFile = File('${workDir.path}/out.apk'); final Directory tmpDir = Directory('${workDir.path}/revanced-temporary-files'); try { await patcherChannel.invokeMethod( 'runPatcher', { 'inFilePath': apkFilePath, 'outFilePath': outFile!.path, 'integrationsPath': integrationsFile.path, 'selectedPatches': selectedPatches.map((p) => p.name).toList(), 'options': options, 'tmpDirPath': tmpDir.path, 'keyStoreFilePath': _keyStoreFile.path, 'keystorePassword': _managerAPI.getKeystorePassword(), }, ); } on Exception catch (e) { if (kDebugMode) { print(e); } } } } Future stopPatcher() async { try { await patcherChannel.invokeMethod('stopPatcher'); } on Exception catch (e) { if (kDebugMode) { print(e); } } } Future installPatchedFile( BuildContext context, PatchedApplication patchedApp, ) async { if (outFile != null) { _managerAPI.ctx = context; try { if (patchedApp.isRooted) { final bool hasRootPermissions = await _rootAPI.hasRootPermissions(); final packageVersion = await DeviceApps.getApp(patchedApp.packageName) .then((app) => app?.versionName); if (!hasRootPermissions) { installErrorDialog(1); } else if (packageVersion == null) { installErrorDialog(1.2); } else if (packageVersion == patchedApp.version) { return await _rootAPI.install( patchedApp.packageName, patchedApp.apkFilePath, outFile!.path, ) ? 0 : 1; } else { installErrorDialog(1.1); } } else { if (await _rootAPI.hasRootPermissions()) { await _rootAPI.uninstall(patchedApp.packageName); } if (context.mounted) { return await installApk( context, outFile!.path, ); } } } on Exception catch (e) { if (kDebugMode) { print(e); } } } return 1; } Future installApk( BuildContext context, String apkPath, ) async { try { final status = await patcherChannel.invokeMethod('installApk', { 'apkPath': apkPath, }); final int statusCode = status['status']; final String message = status['message']; final bool hasExtra = message.contains('INSTALL_FAILED_VERIFICATION_FAILURE') || message.contains('INSTALL_FAILED_VERSION_DOWNGRADE'); if (statusCode == 0 || (statusCode == 3 && !hasExtra)) { return statusCode; } else { _managerAPI.ctx = context; return await installErrorDialog( statusCode, status, hasExtra, ); } } on Exception catch (e) { if (kDebugMode) { print(e); } return 3; } } Future installErrorDialog( num statusCode, [ status, bool hasExtra = false, ]) async { final String statusValue = InstallStatus.byCode( hasExtra ? double.parse('$statusCode.1') : statusCode, ); bool cleanInstall = false; final bool isFixable = statusCode == 4 || statusCode == 5; var description = t['installErrorDialog.${statusValue}_description']; if (statusCode == 2) { description = description( packageName: statusCode == 2 ? { 'packageName': status['otherPackageName'], } : null, ); } await showDialog( context: _managerAPI.ctx!, builder: (context) => AlertDialog( backgroundColor: Theme.of(context).colorScheme.secondaryContainer, title: Text(t['installErrorDialog.$statusValue']), content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(description), ], ), actions: (status == null) ? [ FilledButton( onPressed: () async { Navigator.pop(context); }, child: Text(t.okButton), ), ] : [ if (!isFixable) FilledButton( onPressed: () { Navigator.pop(context); }, child: Text(t.cancelButton), ) else TextButton( onPressed: () { Navigator.pop(context); }, child: Text(t.cancelButton), ), if (isFixable) FilledButton( onPressed: () async { final int response = await patcherChannel.invokeMethod( 'uninstallApp', {'packageName': status['packageName']}, ); if (response == 0 && context.mounted) { cleanInstall = true; Navigator.pop(context); } }, child: Text(t.okButton), ), ], ), ); return cleanInstall ? 10 : 1; } void exportPatchedFile(String appName, String version) { try { if (outFile != null) { final String newName = _getFileName(appName, version); FlutterFileDialog.saveFile( params: SaveFileDialogParams( sourceFilePath: outFile!.path, fileName: newName, mimeTypesFilter: ['application/vnd.android.package-archive'], ), ); } } on Exception catch (e) { if (kDebugMode) { print(e); } } } void sharePatchedFile(String appName, String version) { try { if (outFile != null) { final String newName = _getFileName(appName, version); final int lastSeparator = outFile!.path.lastIndexOf('/'); final String newPath = outFile!.path.substring(0, lastSeparator + 1) + newName; final File shareFile = outFile!.copySync(newPath); Share.shareXFiles([XFile(shareFile.path)]); } } on Exception catch (e) { if (kDebugMode) { print(e); } } } String _getFileName(String appName, String version) { final String patchVersion = _managerAPI.patchesVersion!; final String prefix = appName.toLowerCase().replaceAll(' ', '-'); final String newName = '$prefix-revanced_v$version-patches_$patchVersion.apk'; return newName; } Future exportPatcherLog(String logs) async { final Directory appCache = await getTemporaryDirectory(); final Directory logDir = Directory('${appCache.path}/logs'); logDir.createSync(); final String dateTime = DateTime.now() .toIso8601String() .replaceAll('-', '') .replaceAll(':', '') .replaceAll('T', '') .replaceAll('.', ''); final String fileName = 'revanced-manager_patcher_$dateTime.txt'; final File log = File('${logDir.path}/$fileName'); log.writeAsStringSync(logs); FlutterFileDialog.saveFile( params: SaveFileDialogParams( sourceFilePath: log.path, fileName: fileName, ), ); } String getSuggestedVersion(String packageName) { final Map versions = {}; for (final Patch patch in _patches) { final Package? package = patch.compatiblePackages.firstWhereOrNull( (pack) => pack.name == packageName, ); if (package != null) { for (final String version in package.versions) { versions.update( version, (value) => versions[version]! + 1, ifAbsent: () => 1, ); } } } if (versions.isNotEmpty) { final entries = versions.entries.toList() ..sort((a, b) => a.value.compareTo(b.value)); versions ..clear() ..addEntries(entries); versions.removeWhere((key, value) => value != versions.values.last); return (versions.keys.toList()..sort()).last; } return ''; } } enum InstallStatus { mountNoRoot(1), mountVersionMismatch(1.1), mountMissingInstallation(1.2), statusFailureBlocked(2), installFailedVerificationFailure(3.1), statusFailureInvalid(4), installFailedVersionDowngrade(4.1), statusFailureConflict(5), statusFailureStorage(6), statusFailureIncompatible(7), statusFailureTimeout(8); const InstallStatus(this.statusCode); final double statusCode; static String byCode(num code) { try { return InstallStatus.values .firstWhere((flag) => flag.statusCode == code) .status; } catch (e) { return 'status_unknown'; } } } extension InstallStatusExtension on InstallStatus { String get status { switch (this) { case InstallStatus.mountNoRoot: return 'mount_no_root'; case InstallStatus.mountVersionMismatch: return 'mount_version_mismatch'; case InstallStatus.mountMissingInstallation: return 'mount_missing_installation'; case InstallStatus.statusFailureBlocked: return 'status_failure_blocked'; case InstallStatus.installFailedVerificationFailure: return 'install_failed_verification_failure'; case InstallStatus.statusFailureInvalid: return 'status_failure_invalid'; case InstallStatus.installFailedVersionDowngrade: return 'install_failed_version_downgrade'; case InstallStatus.statusFailureConflict: return 'status_failure_conflict'; case InstallStatus.statusFailureStorage: return 'status_failure_storage'; case InstallStatus.statusFailureIncompatible: return 'status_failure_incompatible'; case InstallStatus.statusFailureTimeout: return 'status_failure_timeout'; } } }