From c01d11305ce718fabba3a8175e5fec24d78281a8 Mon Sep 17 00:00:00 2001 From: Locon213 <167447571+Locon213@users.noreply.github.com> Date: Thu, 7 May 2026 11:57:50 +0300 Subject: [PATCH] feat: add Android dialog mode for external downloads (#1362) --- .../android/app/src/main/AndroidManifest.xml | 42 ++- .../com/gopeed/gopeed/CreateDialogActivity.kt | 31 ++ .../kotlin/com/gopeed/gopeed/MainActivity.kt | 7 +- .../app/src/main/res/values-night/styles.xml | 14 + .../app/src/main/res/values/styles.xml | 14 + .../app/controllers/app_controller.dart | 32 +- .../create/views/create_dialog_view.dart | 299 ++++++++++++++++++ ui/flutter/lib/app/routes/app_pages.dart | 7 + ui/flutter/lib/app/routes/app_routes.dart | 2 + 9 files changed, 427 insertions(+), 21 deletions(-) create mode 100644 ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt create mode 100644 ui/flutter/lib/app/modules/create/views/create_dialog_view.dart diff --git a/ui/flutter/android/app/src/main/AndroidManifest.xml b/ui/flutter/android/app/src/main/AndroidManifest.xml index e349bd1f..56b8329c 100644 --- a/ui/flutter/android/app/src/main/AndroidManifest.xml +++ b/ui/flutter/android/app/src/main/AndroidManifest.xml @@ -27,6 +27,30 @@ android:theme="@style/LaunchTheme" android:windowSoftInputMode="adjustResize"> + + + + + + + + + + @@ -83,18 +107,6 @@ - - - - - - - @@ -102,6 +114,10 @@ + + @@ -116,4 +132,4 @@ android:name="flutterEmbedding" android:value="2" /> - \ No newline at end of file + diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt new file mode 100644 index 00000000..6dc1497b --- /dev/null +++ b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/CreateDialogActivity.kt @@ -0,0 +1,31 @@ +package com.gopeed.gopeed + +import android.os.Bundle +import android.view.Gravity +import android.view.WindowManager +import io.flutter.embedding.android.FlutterActivityLaunchConfigs + +class CreateDialogActivity : MainActivity() { + override fun isDialogMode(): Boolean = true + + override fun getBackgroundMode(): FlutterActivityLaunchConfigs.BackgroundMode { + return FlutterActivityLaunchConfigs.BackgroundMode.transparent + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val metrics = resources.displayMetrics + val width = (metrics.widthPixels * 0.92f).toInt() + val maxHeight = (metrics.heightPixels * 0.62f).toInt() + val preferredHeight = (420 * metrics.density).toInt() + + window.setGravity(Gravity.CENTER) + window.setDimAmount(0.28f) + window.setLayout(width, minOf(maxHeight, preferredHeight)) + + val attrs = window.attributes + attrs.flags = attrs.flags or WindowManager.LayoutParams.FLAG_DIM_BEHIND + window.attributes = attrs + } +} diff --git a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt index 51a5eb01..d054f1da 100644 --- a/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt +++ b/ui/flutter/android/app/src/main/kotlin/com/gopeed/gopeed/MainActivity.kt @@ -7,9 +7,11 @@ import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.StandardMethodCodec -class MainActivity : FlutterActivity() { +open class MainActivity : FlutterActivity() { private val CHANNEL = "gopeed.com/libgopeed" + protected open fun isDialogMode(): Boolean = false + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) val taskQueue = @@ -34,6 +36,9 @@ class MainActivity : FlutterActivity() { Libgopeed.stop() result.success(null) } + "isDialogMode" -> { + result.success(isDialogMode()) + } else -> { result.notImplemented() } diff --git a/ui/flutter/android/app/src/main/res/values-night/styles.xml b/ui/flutter/android/app/src/main/res/values-night/styles.xml index 06952be7..7c601a8b 100644 --- a/ui/flutter/android/app/src/main/res/values-night/styles.xml +++ b/ui/flutter/android/app/src/main/res/values-night/styles.xml @@ -15,4 +15,18 @@ + + diff --git a/ui/flutter/android/app/src/main/res/values/styles.xml b/ui/flutter/android/app/src/main/res/values/styles.xml index cb1ef880..36c4b265 100644 --- a/ui/flutter/android/app/src/main/res/values/styles.xml +++ b/ui/flutter/android/app/src/main/res/values/styles.xml @@ -15,4 +15,18 @@ + + diff --git a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart index 2bafbd26..58851a11 100644 --- a/ui/flutter/lib/app/modules/app/controllers/app_controller.dart +++ b/ui/flutter/lib/app/modules/app/controllers/app_controller.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:get/get.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; @@ -69,6 +70,7 @@ class PendingUpdateTask { class AppController extends GetxController with WindowListener, TrayListener { static StartConfig? _defaultStartConfig; + static const _nativeChannel = MethodChannel('gopeed.com/libgopeed'); /// Command line --hidden flag passed from main.dart final bool hiddenFromArgs; @@ -79,6 +81,7 @@ class AppController extends GetxController with WindowListener, TrayListener { final startConfig = StartConfig().obs; final runningPort = 0.obs; final downloaderConfig = DownloaderConfig().obs; + var androidDialogMode = false; /// The task that is pending URL update via listen mode. /// Stored here in AppController to persist across page navigations. @@ -124,9 +127,11 @@ class AppController extends GetxController with WindowListener, TrayListener { void onClose() { _linkSubscription?.cancel(); trayManager.removeListener(this); - HostRpcService.instance.stop(); - WebViewRpcService.instance.stop(); - LibgopeedBoot.instance.stop(); + if (!androidDialogMode) { + HostRpcService.instance.stop(); + WebViewRpcService.instance.stop(); + LibgopeedBoot.instance.stop(); + } } @override @@ -182,6 +187,7 @@ class AppController extends GetxController with WindowListener, TrayListener { // For web, just show window return; } + androidDialogMode = await _isAndroidDialogMode(); // Handle deep link _appLinks = AppLinks(); @@ -241,6 +247,18 @@ class AppController extends GetxController with WindowListener, TrayListener { } } + Future _isAndroidDialogMode() async { + if (!Util.isAndroid()) { + return false; + } + try { + return await _nativeChannel.invokeMethod('isDialogMode') ?? false; + } catch (e) { + logger.w("get android dialog mode fail", e); + return false; + } + } + Future _initWindows() async { if (!Util.isDesktop()) { return; @@ -440,9 +458,7 @@ class AppController extends GetxController with WindowListener, TrayListener { } else { path = (await toFile(uri.toString())).path; } - Get.rootDelegate.offAndToNamed(Routes.REDIRECT, - arguments: RedirectArgs(Routes.CREATE, - arguments: CreateTask(req: Request(url: path)))); + _handleToCreate0(CreateTask(req: Request(url: path))); } String runningAddress() { @@ -697,8 +713,10 @@ class AppController extends GetxController with WindowListener, TrayListener { } _handleToCreate0(CreateTask createTaskParams) { + final targetRoute = + androidDialogMode ? Routes.QUICK_CREATE : Routes.CREATE; Get.rootDelegate.offAndToNamed(Routes.REDIRECT, - arguments: RedirectArgs(Routes.CREATE, arguments: createTaskParams)); + arguments: RedirectArgs(targetRoute, arguments: createTaskParams)); } _handleToExtension(String params) { diff --git a/ui/flutter/lib/app/modules/create/views/create_dialog_view.dart b/ui/flutter/lib/app/modules/create/views/create_dialog_view.dart new file mode 100644 index 00000000..4d5832e2 --- /dev/null +++ b/ui/flutter/lib/app/modules/create/views/create_dialog_view.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:path/path.dart' as path; + +import '../../../../api/api.dart'; +import '../../../../api/model/create_task.dart'; +import '../../../../api/model/create_task_batch.dart'; +import '../../../../api/model/options.dart'; +import '../../../../api/model/resolve_result.dart'; +import '../../../../api/model/resolve_task.dart'; +import '../../../../util/message.dart'; +import '../../../views/directory_selector.dart'; +import '../../app/controllers/app_controller.dart'; + +class CreateDialogView extends StatefulWidget { + const CreateDialogView({Key? key}) : super(key: key); + + @override + State createState() => _CreateDialogViewState(); +} + +class _CreateDialogViewState extends State { + final _pathController = TextEditingController(); + final _nameController = TextEditingController(); + + CreateTask? _createTask; + ResolveResult? _resolveResult; + Object? _error; + bool _resolving = true; + bool _creating = false; + + @override + void initState() { + super.initState(); + final appController = Get.find(); + _pathController.text = renderPathPlaceholders( + appController.downloaderConfig.value.downloadDir, + ); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadArguments()); + } + + @override + void dispose() { + _pathController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _loadArguments() async { + final args = Get.rootDelegate.arguments(); + if (args is! CreateTask || args.req?.url.isNotEmpty != true) { + if (mounted) { + setState(() { + _resolving = false; + }); + } + return; + } + + _createTask = args; + if (args.opts?.path.isNotEmpty == true) { + _pathController.text = args.opts!.path; + } + if (args.opts?.name.isNotEmpty == true) { + _nameController.text = args.opts!.name; + } else { + _nameController.text = _fallbackName(args.req!.url); + } + + await _resolve(args); + } + + Future _resolve(CreateTask createTask) async { + setState(() { + _resolving = true; + _error = null; + }); + + try { + final opt = Options( + name: createTask.opts?.name ?? '', + path: _pathController.text, + selectFiles: const [], + extra: _optsExtra(), + ); + final result = await resolve( + ResolveTask(req: createTask.req!, opts: opt), + ); + if (!mounted) return; + setState(() { + _resolveResult = result; + if (_nameController.text.trim().isEmpty || + _nameController.text == _fallbackName(createTask.req!.url)) { + _nameController.text = _resolvedName(result, createTask.req!.url); + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e; + }); + } finally { + if (mounted) { + setState(() { + _resolving = false; + }); + } + } + } + + Future _confirm() async { + final createTaskParams = _createTask; + if (createTaskParams?.req == null) { + return; + } + + setState(() { + _creating = true; + }); + + try { + final rr = _resolveResult; + final optExtra = _optsExtra(); + if (rr == null) { + await createTask( + CreateTask( + req: createTaskParams!.req, + opts: Options( + name: _nameController.text.trim(), + path: _pathController.text, + selectFiles: const [], + extra: optExtra, + ), + ), + ); + } else if (rr.id.isEmpty) { + final reqs = rr.res.files.map((file) { + return CreateTaskBatchItem( + req: file.req ?? createTaskParams!.req, + opts: Options( + name: file.name, + path: path.join(_pathController.text, rr.res.name, file.path), + selectFiles: const [], + extra: optExtra, + ), + ); + }).toList(); + await createTaskBatch(CreateTaskBatch(reqs: reqs)); + } else { + await createTask( + CreateTask( + rid: rr.id, + opts: Options( + name: _nameController.text.trim(), + path: _pathController.text, + selectFiles: rr.res.files.asMap().keys.toList(), + extra: optExtra, + ), + ), + ); + } + await SystemNavigator.pop(); + } catch (e) { + showErrorMessage(e); + if (mounted) { + setState(() { + _creating = false; + }); + } + } + } + + Object _optsExtra() { + final httpConfig = + Get.find().downloaderConfig.value.protocolConfig.http; + return OptsExtraHttp(connections: httpConfig.connections); + } + + String _resolvedName(ResolveResult result, String url) { + if (result.res.name.trim().isNotEmpty) { + return result.res.name; + } + if (result.res.files.length == 1) { + return result.res.files.first.name; + } + return _fallbackName(url); + } + + String _fallbackName(String url) { + final uri = Uri.tryParse(url); + final uriName = uri == null ? '' : path.basename(uri.path); + if (uriName.isNotEmpty && uriName != '.') { + return uriName; + } + return url; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Center( + child: Material( + color: theme.colorScheme.surface, + elevation: 16, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'create'.tr, + style: theme.textTheme.titleMedium, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: _creating ? null : SystemNavigator.pop, + tooltip: 'cancel'.tr, + ), + ], + ), + const SizedBox(height: 8), + if (_resolving) + const LinearProgressIndicator(minHeight: 2) + else + const SizedBox(height: 2), + const SizedBox(height: 14), + TextField( + controller: _nameController, + enabled: !_creating, + decoration: InputDecoration( + labelText: 'rename'.tr, + prefixIcon: const Icon(Icons.insert_drive_file), + ), + maxLines: 1, + ), + const SizedBox(height: 12), + DirectorySelector( + controller: _pathController, + showAndoirdToggle: true, + allowEdit: true, + ), + if (_error != null) ...[ + const SizedBox(height: 12), + Text( + _error.toString(), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 18), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _creating ? null : SystemNavigator.pop, + child: Text('cancel'.tr), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: + (_resolving || _creating) ? null : _confirm, + child: _creating + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : Text('download'.tr), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/ui/flutter/lib/app/routes/app_pages.dart b/ui/flutter/lib/app/routes/app_pages.dart index f3965d4d..9b890179 100644 --- a/ui/flutter/lib/app/routes/app_pages.dart +++ b/ui/flutter/lib/app/routes/app_pages.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import '../modules/create/bindings/create_binding.dart'; +import '../modules/create/views/create_dialog_view.dart'; import '../modules/create/views/create_view.dart'; import '../modules/extension/bindings/extension_binding.dart'; import '../modules/extension/views/extension_view.dart'; @@ -78,6 +79,12 @@ class AppPages { page: () => CreateView(), binding: CreateBinding(), ), + GetPage( + name: _Paths.QUICK_CREATE, + transition: Transition.noTransition, + page: () => const CreateDialogView(), + binding: CreateBinding(), + ), GetPage( name: _Paths.REDIRECT, page: () => const RedirectView(), diff --git a/ui/flutter/lib/app/routes/app_routes.dart b/ui/flutter/lib/app/routes/app_routes.dart index 17599085..40b7b4b8 100644 --- a/ui/flutter/lib/app/routes/app_routes.dart +++ b/ui/flutter/lib/app/routes/app_routes.dart @@ -7,6 +7,7 @@ abstract class Routes { static const ROOT = _Paths.ROOT; static const HOME = _Paths.HOME; static const CREATE = _Paths.CREATE; + static const QUICK_CREATE = _Paths.QUICK_CREATE; static const LOGIN = _Paths.LOGIN; static const TASK = _Paths.HOME + _Paths.TASK; static const TASK_FILES = TASK + _Paths.TASK_FILES; @@ -21,6 +22,7 @@ abstract class _Paths { static const ROOT = '/'; static const HOME = '/home'; static const CREATE = '/create'; + static const QUICK_CREATE = '/quick-create'; static const LOGIN = '/login'; static const TASK = '/task'; static const TASK_FILES = '/files';