diff --git a/android/app/build.gradle b/android/app/build.gradle index 42a9a7d0f2..a1b26cc8c5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -85,7 +85,7 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" // ReVanced - implementation "app.revanced:revanced-patcher:17.0.0" + implementation "app.revanced:revanced-patcher:19.0.0" // Signing & aligning implementation("org.bouncycastle:bcpkix-jdk15on:1.70") diff --git a/android/app/src/main/kotlin/app/revanced/manager/flutter/MainActivity.kt b/android/app/src/main/kotlin/app/revanced/manager/flutter/MainActivity.kt index 77d08d1d17..1a804699aa 100644 --- a/android/app/src/main/kotlin/app/revanced/manager/flutter/MainActivity.kt +++ b/android/app/src/main/kotlin/app/revanced/manager/flutter/MainActivity.kt @@ -15,7 +15,9 @@ import app.revanced.patcher.patch.PatchResult import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.runBlocking import org.json.JSONArray import org.json.JSONObject @@ -25,6 +27,7 @@ import java.io.StringWriter import java.util.logging.LogRecord import java.util.logging.Logger + class MainActivity : FlutterActivity() { private val handler = Handler(Looper.getMainLooper()) private lateinit var installerChannel: MethodChannel @@ -131,24 +134,34 @@ class MainActivity : FlutterActivity() { }) put("options", JSONArray().apply { it.options.values.forEach { option -> - val optionJson = JSONObject().apply option@{ + JSONObject().apply { put("key", option.key) put("title", option.title) put("description", option.description) put("required", option.required) - when (val value = option.value) { - null -> put("value", null) - is Array<*> -> put("value", JSONArray().apply { - + fun JSONObject.putValue( + value: Any?, + key: String = "value" + ) = if (value is Array<*>) put( + key, + JSONArray().apply { value.forEach { put(it) } }) - else -> put("value", option.value) - } - - put("optionClassType", option::class.simpleName) - } - put(optionJson) + else put(key, value) + + putValue(option.default) + + option.values?.let { values -> + put("values", + JSONObject().apply { + values.forEach { (key, value) -> + putValue(value, key) + } + }) + } ?: put("values", null) + put("valueType", option.valueType) + }.let(::put) } }) }.let(::put) @@ -161,6 +174,7 @@ class MainActivity : FlutterActivity() { } } + @OptIn(InternalCoroutinesApi::class) private fun runPatcher( result: MethodChannel.Result, originalFilePath: String, @@ -283,12 +297,12 @@ class MainActivity : FlutterActivity() { acceptPatches(patches) runBlocking { - apply(false).collect { patchResult: PatchResult -> + apply(false).collect(FlowCollector { patchResult: PatchResult -> if (cancel) { handler.post { stopResult!!.success(null) } this.cancel() this@apply.close() - return@collect + return@FlowCollector } val msg = patchResult.exception?.let { @@ -301,7 +315,7 @@ class MainActivity : FlutterActivity() { updateProgress(progress, "", msg) progress += progressStep - } + }) } } diff --git a/assets/i18n/en_US.json b/assets/i18n/en_US.json index b94bf7e3d6..86b2713176 100644 --- a/assets/i18n/en_US.json +++ b/assets/i18n/en_US.json @@ -135,6 +135,7 @@ "setRequiredOption": "Some patches require options to be set:\n\n{patches}\n\nPlease set them before continuing." }, "patchOptionsView": { + "customValue": "Custom value", "resetOptionsTooltip": "Reset patch options", "viewTitle": "Patch options", "saveOptions": "Save", diff --git a/lib/models/patch.dart b/lib/models/patch.dart index e90787735d..199cbb87e6 100644 --- a/lib/models/patch.dart +++ b/lib/models/patch.dart @@ -13,12 +13,15 @@ class Patch { }); factory Patch.fromJson(Map json) { - // See: https://github.com/ReVanced/revanced-manager/issues/1364#issuecomment-1760414618 + _migrateV16ToV17(json); + + return _$PatchFromJson(json); + } + + static void _migrateV16ToV17(Map json) { if (json['options'] == null) { json['options'] = []; } - - return _$PatchFromJson(json); } final String name; @@ -57,18 +60,34 @@ class Option { required this.title, required this.description, required this.value, + required this.values, required this.required, - required this.optionClassType, + required this.valueType, }); - factory Option.fromJson(Map json) => _$OptionFromJson(json); + factory Option.fromJson(Map json) { + _migrateV17ToV19(json); + + return _$OptionFromJson(json); + } + + static void _migrateV17ToV19(Map json) { + if (json['valueType'] == null) { + json['valueType'] = json['optionClassType'] + .replace('PatchOption', '') + .replace('List', 'Array'); + + json['optionClassType'] = null; + } + } final String key; final String title; final String description; - dynamic value; + final dynamic value; + final Map? values; final bool required; - final String optionClassType; + final String valueType; Map toJson() => _$OptionToJson(this); } diff --git a/lib/ui/views/patch_options/patch_options_view.dart b/lib/ui/views/patch_options/patch_options_view.dart index 7a21eb4901..e35b849da3 100644 --- a/lib/ui/views/patch_options/patch_options_view.dart +++ b/lib/ui/views/patch_options/patch_options_view.dart @@ -61,8 +61,8 @@ class PatchOptionsView extends StatelessWidget { child: Column( children: [ for (final Option option in model.visibleOptions) - if (option.optionClassType == 'StringPatchOption' || - option.optionClassType == 'IntPatchOption') + if (option.valueType == 'String' || + option.valueType == 'Int') IntAndStringPatchOption( patchOption: option, removeOption: (option) { @@ -72,7 +72,7 @@ class PatchOptionsView extends StatelessWidget { model.modifyOptions(value, option); }, ) - else if (option.optionClassType == 'BooleanPatchOption') + else if (option.valueType == 'Boolean') BooleanPatchOption( patchOption: option, removeOption: (option) { @@ -82,10 +82,10 @@ class PatchOptionsView extends StatelessWidget { model.modifyOptions(value, option); }, ) - else if (option.optionClassType == - 'StringListPatchOption' || - option.optionClassType == 'IntListPatchOption' || - option.optionClassType == 'LongListPatchOption') + else if (option.valueType == + 'StringArray' || + option.valueType == 'IntArray' || + option.valueType == 'LongArray') IntStringLongListPatchOption( patchOption: option, removeOption: (option) { diff --git a/lib/ui/views/patch_options/patch_options_viewmodel.dart b/lib/ui/views/patch_options/patch_options_viewmodel.dart index b8813d4941..c1da79920b 100644 --- a/lib/ui/views/patch_options/patch_options_viewmodel.dart +++ b/lib/ui/views/patch_options/patch_options_viewmodel.dart @@ -62,7 +62,10 @@ class PatchOptionsViewModel extends BaseViewModel { for (final Option option in options) { if (!visibleOptions.any((vOption) => vOption.key == option.key)) { _managerAPI.clearPatchOption( - selectedApp, _managerAPI.selectedPatch!.name, option.key); + selectedApp, + _managerAPI.selectedPatch!.name, + option.key, + ); } } for (final Option option in visibleOptions) { @@ -70,7 +73,10 @@ class PatchOptionsViewModel extends BaseViewModel { requiredNullOptions.add(option); } else { _managerAPI.setPatchOption( - option, _managerAPI.selectedPatch!.name, selectedApp); + option, + _managerAPI.selectedPatch!.name, + selectedApp, + ); } } if (requiredNullOptions.isNotEmpty) { @@ -89,7 +95,8 @@ class PatchOptionsViewModel extends BaseViewModel { final Option modifiedOption = Option( title: option.title, description: option.description, - optionClassType: option.optionClassType, + values: option.values, + valueType: option.valueType, value: value, required: option.required, key: option.key, @@ -107,7 +114,8 @@ class PatchOptionsViewModel extends BaseViewModel { final Option defaultOption = Option( title: option.title, description: option.description, - optionClassType: option.optionClassType, + values: option.values, + valueType: option.valueType, value: option.value is List ? option.value.toList() : option.value, required: option.required, key: option.key, @@ -172,21 +180,27 @@ class PatchOptionsViewModel extends BaseViewModel { }, child: Padding( padding: const EdgeInsets.all(8.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - e.title, - style: const TextStyle( - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - e.description, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSurface, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.title, + style: const TextStyle( + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + e.description, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ], ), ), ], @@ -229,7 +243,10 @@ Future showRequiredOptionNullDialog( locator().notifyListeners(); for (final option in options) { managerAPI.clearPatchOption( - selectedApp, managerAPI.selectedPatch!.name, option.key); + selectedApp, + managerAPI.selectedPatch!.name, + option.key, + ); } Navigator.of(context) ..pop() diff --git a/lib/ui/widgets/patchesSelectorView/patch_options_fields.dart b/lib/ui/widgets/patchesSelectorView/patch_options_fields.dart index e02021cf90..5b48dbcf78 100644 --- a/lib/ui/widgets/patchesSelectorView/patch_options_fields.dart +++ b/lib/ui/widgets/patchesSelectorView/patch_options_fields.dart @@ -59,13 +59,27 @@ class IntAndStringPatchOption extends StatelessWidget { @override Widget build(BuildContext context) { final ValueNotifier patchOptionValue = ValueNotifier(patchOption.value); + String getKey() { + if (patchOption.value != null && patchOption.values != null) { + final List values = patchOption.values!.entries + .where((e) => e.value == patchOption.value) + .toList(); + if (values.isNotEmpty) { + return values.first.key; + } + } + return ''; + } + return PatchOption( widget: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextFieldForPatchOption( value: patchOption.value, - optionType: patchOption.optionClassType, + values: patchOption.values, + optionType: patchOption.valueType, + selectedKey: getKey(), onChanged: (value) { patchOptionValue.value = value; onChanged(value, patchOption); @@ -119,17 +133,41 @@ class IntStringLongListPatchOption extends StatelessWidget { @override Widget build(BuildContext context) { - final String type = patchOption.optionClassType; - final List values = patchOption.value ?? []; + final List values = List.from(patchOption.value ?? []); final ValueNotifier patchOptionValue = ValueNotifier(values); + final String type = patchOption.valueType; + + String getKey(dynamic value) { + if (value != null && patchOption.values != null) { + final List values = patchOption.values!.entries + .where((e) => e.value.toString() == value) + .toList(); + if (values.isNotEmpty) { + return values.first.key; + } + } + return ''; + } + + bool isCustomValue() { + if (values.length == 1 && patchOption.values != null) { + if (getKey(values[0]) != '') { + return false; + } + } + return true; + } + + bool isTextFieldVisible = isCustomValue(); + return PatchOption( - widget: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ValueListenableBuilder( - valueListenable: patchOptionValue, - builder: (context, value, child) { - return ListView.builder( + widget: ValueListenableBuilder( + valueListenable: patchOptionValue, + builder: (context, value, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListView.builder( shrinkWrap: true, itemCount: value.length, physics: const NeverScrollableScrollPhysics(), @@ -137,16 +175,42 @@ class IntStringLongListPatchOption extends StatelessWidget { final e = values[index]; return TextFieldForPatchOption( value: e.toString(), + values: patchOption.values, optionType: type, + selectedKey: value.length > 1 ? '' : getKey(e), + showDropdown: index == 0, onChanged: (newValue) { - values[index] = type == 'StringListPatchOption' - ? newValue - : type == 'IntListPatchOption' - ? int.parse(newValue) - : num.parse(newValue); + if (newValue is List) { + values.clear(); + isTextFieldVisible = false; + values.add(newValue.toString()); + } else { + isTextFieldVisible = true; + if (values.length == 1 && + values[0].toString().startsWith('[') && + type.contains('Array')) { + values.clear(); + values.addAll(patchOption.value); + } else { + values[index] = type == 'StringArray' + ? newValue + : type == 'IntArray' + ? int.parse( + newValue.toString().isEmpty + ? '0' + : newValue.toString(), + ) + : num.parse( + newValue.toString().isEmpty + ? '0' + : newValue.toString(), + ); + } + } + patchOptionValue.value = List.from(values); onChanged(values, patchOption); }, - removeValue: (value) { + removeValue: () { patchOptionValue.value = List.from(patchOptionValue.value) ..removeAt(index); values.removeAt(index); @@ -154,44 +218,46 @@ class IntStringLongListPatchOption extends StatelessWidget { }, ); }, - ); - }, - ), - const SizedBox(height: 4), - Align( - alignment: Alignment.centerLeft, - child: TextButton( - onPressed: () { - if (type == 'StringListPatchOption') { - patchOptionValue.value = List.from(patchOptionValue.value) - ..add(''); - values.add(''); - } else { - patchOptionValue.value = List.from(patchOptionValue.value) - ..add(0); - values.add(0); - } - onChanged(values, patchOption); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.add, size: 20), - I18nText( - 'add', - child: const Text( - '', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), + ), + if (isTextFieldVisible) ...[ + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () { + if (type == 'StringArray') { + patchOptionValue.value = + List.from(patchOptionValue.value)..add(''); + values.add(''); + } else { + patchOptionValue.value = + List.from(patchOptionValue.value)..add(0); + values.add(0); + } + onChanged(values, patchOption); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.add, size: 20), + I18nText( + 'add', + child: const Text( + '', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ], ), ), - ], - ), - ), - ), - ], + ), + ], + ], + ); + }, ), patchOption: patchOption, removeOption: (Option option) { @@ -203,6 +269,7 @@ class IntStringLongListPatchOption extends StatelessWidget { class UnsupportedPatchOption extends StatelessWidget { const UnsupportedPatchOption({super.key, required this.patchOption}); + final Option patchOption; @override @@ -302,14 +369,20 @@ class TextFieldForPatchOption extends StatefulWidget { const TextFieldForPatchOption({ super.key, required this.value, + required this.values, this.removeValue, required this.onChanged, required this.optionType, + required this.selectedKey, + this.showDropdown = true, }); final String? value; + final Map? values; final String optionType; - final void Function(dynamic value)? removeValue; + final String selectedKey; + final bool showDropdown; + final void Function()? removeValue; final void Function(dynamic value) onChanged; @override @@ -319,75 +392,156 @@ class TextFieldForPatchOption extends StatefulWidget { class _TextFieldForPatchOptionState extends State { final TextEditingController controller = TextEditingController(); + String? selectedKey; + String? defaultValue; + @override Widget build(BuildContext context) { final bool isStringOption = widget.optionType.contains('String'); - final bool isListOption = widget.optionType.contains('List'); - controller.text = widget.value ?? ''; - return TextFormField( - inputFormatters: [ - if (widget.optionType.contains('Int')) - FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), - if (widget.optionType.contains('Long')) - FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*\.?[0-9]*')), - ], - controller: controller, - keyboardType: isStringOption ? TextInputType.text : TextInputType.number, - decoration: InputDecoration( - suffixIcon: PopupMenuButton( - tooltip: FlutterI18n.translate( - context, - 'patchOptionsView.tooltip', - ), - itemBuilder: (BuildContext context) { - return [ - if (isListOption) - PopupMenuItem( - value: 'remove', - child: I18nText('remove'), - ), - if (isStringOption && !isListOption) ...[ - PopupMenuItem( - value: 'patchOptionsView.selectFilePath', - child: I18nText('patchOptionsView.selectFilePath'), + final bool isArrayOption = widget.optionType.contains('Array'); + selectedKey ??= widget.selectedKey; + controller.text = !isStringOption && isArrayOption && selectedKey == '' && + (widget.value != null && widget.value.toString().startsWith('[')) + ? '' + : widget.value ?? ''; + defaultValue ??= controller.text; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showDropdown && (widget.values?.isNotEmpty ?? false)) + DropdownButton( + style: const TextStyle( + fontSize: 16, + ), + borderRadius: BorderRadius.circular(4), + dropdownColor: Theme.of(context).colorScheme.secondaryContainer, + isExpanded: true, + value: selectedKey, + items: widget.values!.entries + .map( + (e) => DropdownMenuItem( + value: e.key, + child: RichText( + text: TextSpan( + text: e.key, + style: const TextStyle( + fontSize: 16, + ), + children: [ + TextSpan( + text: ' ${e.value}', + style: TextStyle( + fontSize: 14, + color: Theme.of(context) + .colorScheme + .onSecondaryContainer + .withOpacity(0.6), + ), + ), + ], + ), + ), + ), + ) + .toList() + ..add( + DropdownMenuItem( + value: '', + child: I18nText( + 'patchOptionsView.customValue', + child: const Text( + '', + style: TextStyle( + fontSize: 16, + ), + ), + ), ), - PopupMenuItem( - value: 'patchOptionsView.selectFolder', - child: I18nText('patchOptionsView.selectFolder'), + ), + onChanged: (value) { + if (value == '') { + controller.text = defaultValue!; + widget.onChanged(controller.text); + } else { + controller.text = widget.values![value].toString(); + widget.onChanged( + isArrayOption ? widget.values![value] : controller.text, + ); + } + setState(() { + selectedKey = value; + }); + }, + ), + if (selectedKey == '') + TextFormField( + inputFormatters: [ + if (widget.optionType.contains('Int')) + FilteringTextInputFormatter.allow(RegExp(r'[0-9]')), + if (widget.optionType.contains('Long')) + FilteringTextInputFormatter.allow(RegExp(r'^[0-9]*\.?[0-9]*')), + ], + controller: controller, + keyboardType: + isStringOption ? TextInputType.text : TextInputType.number, + decoration: InputDecoration( + suffixIcon: PopupMenuButton( + tooltip: FlutterI18n.translate( + context, + 'patchOptionsView.tooltip', ), - ], - ]; - }, - onSelected: (String selection) async { - switch (selection) { - case 'patchOptionsView.selectFilePath': - final result = await FilePicker.platform.pickFiles(); - if (result != null && result.files.single.path != null) { - controller.text = result.files.single.path.toString(); - widget.onChanged(controller.text); - } - break; - case 'patchOptionsView.selectFolder': - final result = await FilePicker.platform.getDirectoryPath(); - if (result != null) { - controller.text = result; - widget.onChanged(controller.text); - } - break; - case 'remove': - widget.removeValue!(widget.value); - break; - } - }, - ), - hintStyle: TextStyle( - fontSize: 14, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - ), - onChanged: (String value) { - widget.onChanged(value); - }, + itemBuilder: (BuildContext context) { + return [ + if (isArrayOption) + PopupMenuItem( + value: 'remove', + child: I18nText('remove'), + ), + if (isStringOption) ...[ + PopupMenuItem( + value: 'patchOptionsView.selectFilePath', + child: I18nText('patchOptionsView.selectFilePath'), + ), + PopupMenuItem( + value: 'patchOptionsView.selectFolder', + child: I18nText('patchOptionsView.selectFolder'), + ), + ], + ]; + }, + onSelected: (String selection) async { + switch (selection) { + case 'patchOptionsView.selectFilePath': + final result = await FilePicker.platform.pickFiles(); + if (result != null && result.files.single.path != null) { + controller.text = result.files.single.path.toString(); + widget.onChanged(controller.text); + } + break; + case 'patchOptionsView.selectFolder': + final result = + await FilePicker.platform.getDirectoryPath(); + if (result != null) { + controller.text = result; + widget.onChanged(controller.text); + } + break; + case 'remove': + widget.removeValue!(); + break; + } + }, + ), + hintStyle: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + onChanged: (String value) { + widget.onChanged(value); + }, + ), + ], ); } } diff --git a/lib/utils/check_for_supported_patch.dart b/lib/utils/check_for_supported_patch.dart index dc2d19361f..19bd30c169 100644 --- a/lib/utils/check_for_supported_patch.dart +++ b/lib/utils/check_for_supported_patch.dart @@ -17,12 +17,12 @@ bool isPatchSupported(Patch patch) { bool hasUnsupportedRequiredOption(List