feat: add Android dialog mode for external downloads (#1362)

This commit is contained in:
Locon213 authored and GitHub committed 2026-05-07 16:57:50 +08:00
1 parent def54f8998
commit c01d11305c
9 files changed
+427 -21

No files matched your search

@@ -27,6 +27,30 @@
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".CreateDialogActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:excludeFromRecents="true"
android:exported="true"
android:finishOnTaskLaunch="true"
android:hardwareAccelerated="true"
android:launchMode="singleTask"
android:theme="@style/DialogLaunchTheme"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -83,18 +107,6 @@
<action android:name="android.intent.action.VIEW_DOWNLOADS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
@@ -102,6 +114,10 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/DialogNormalTheme" />
</activity>
<!-- Add android:stopWithTask option only when necessary. -->
@@ -116,4 +132,4 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
</manifest>
</manifest>
@@ -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
}
}
@@ -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()
}
@@ -15,4 +15,18 @@
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="DialogLaunchTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowNoTitle">true</item>
</style>
<style name="DialogNormalTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
@@ -15,4 +15,18 @@
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
<style name="DialogLaunchTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowNoTitle">true</item>
</style>
<style name="DialogNormalTheme" parent="@android:style/Theme.Translucent.NoTitleBar">
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowIsFloating">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
@@ -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<bool> _isAndroidDialogMode() async {
if (!Util.isAndroid()) {
return false;
}
try {
return await _nativeChannel.invokeMethod<bool>('isDialogMode') ?? false;
} catch (e) {
logger.w("get android dialog mode fail", e);
return false;
}
}
Future<void> _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) {
@@ -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<CreateDialogView> createState() => _CreateDialogViewState();
}
class _CreateDialogViewState extends State<CreateDialogView> {
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<AppController>();
_pathController.text = renderPathPlaceholders(
appController.downloaderConfig.value.downloadDir,
);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadArguments());
}
@override
void dispose() {
_pathController.dispose();
_nameController.dispose();
super.dispose();
}
Future<void> _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<void> _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<void> _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<AppController>().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),
),
],
),
],
),
),
),
),
),
),
);
}
}
+7
View File
@@ -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(),
@@ -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';