From 99924296ef2f48633703adce44027826ca453d8a Mon Sep 17 00:00:00 2001 From: Afterain <92244300+Aftera1n@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:08:53 +0800 Subject: [PATCH] feat: Background download on iOS (#1442) --- .../ios/Runner.xcodeproj/project.pbxproj | 40 +++--- ui/flutter/ios/Runner/AppDelegate.swift | 28 ++++ ui/flutter/ios/Runner/Info.plist | 10 ++ .../ios/Runner/LocationKeepAliveManager.swift | 126 ++++++++++++++++++ ui/flutter/lib/api/api.dart | 37 +++-- .../lib/api/model/downloader_config.dart | 2 + .../lib/api/model/downloader_config.g.dart | 3 + .../modules/setting/views/setting_view.dart | 41 ++++++ .../controllers/task_list_controller.dart | 1 + .../lib/app/services/location_keep_alive.dart | 32 +++++ .../location_keep_alive_coordinator.dart | 75 +++++++++++ ui/flutter/lib/i18n/langs/en_us.dart | 1 + ui/flutter/lib/i18n/langs/zh_cn.dart | 1 + ui/flutter/lib/main.dart | 12 ++ 14 files changed, 383 insertions(+), 26 deletions(-) create mode 100644 ui/flutter/ios/Runner/LocationKeepAliveManager.swift create mode 100644 ui/flutter/lib/app/services/location_keep_alive.dart create mode 100644 ui/flutter/lib/app/services/location_keep_alive_coordinator.dart diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index dcced8b4..1dc4cb6b 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -21,6 +21,8 @@ D0E623A929482D160001185E /* Libgopeed.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; }; D0E623AA29482D160001185E /* Libgopeed.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0E623A729482D030001185E /* Libgopeed.xcframework */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; DCA5B0FA95A8007ADEDACA1B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70BC8D37FAC641C7F20125E8 /* Pods_Runner.framework */; }; + A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* LocationKeepAliveManager.swift */;}; + A10000000000000000000002 /* LocationKeepAliveManager.swift */ = {isa = PBXFileReference;lastKnownFileType = sourcecode.swift;path = LocationKeepAliveManager.swift;sourceTree = "";}; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -153,18 +155,19 @@ sourceTree = ""; }; 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */, - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); + isa = PBXGroup; + children = ( + 0C585CC72D41E36800FF2EC0 /* Runner.entitlements */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + A10000000000000000000002 /* LocationKeepAliveManager.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); path = Runner; sourceTree = ""; }; @@ -376,12 +379,13 @@ runOnlyForDeploymentPostprocessing = 0; }; 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - ); + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ diff --git a/ui/flutter/ios/Runner/AppDelegate.swift b/ui/flutter/ios/Runner/AppDelegate.swift index a4888265..cf13bafa 100644 --- a/ui/flutter/ios/Runner/AppDelegate.swift +++ b/ui/flutter/ios/Runner/AppDelegate.swift @@ -32,6 +32,34 @@ import Libgopeed } }) + let locationChannel = FlutterMethodChannel( + name: "gopeed/location_keep_alive", + binaryMessenger: controller.binaryMessenger +) + + locationChannel.setMethodCallHandler { + (call: FlutterMethodCall, result: @escaping FlutterResult) in + + switch call.method { + + case "requestPermission": + LocationKeepAliveManager.shared.requestPermission { authorized in + result(authorized) + } + + case "start": + LocationKeepAliveManager.shared.start() + result(nil) + + case "stop": + LocationKeepAliveManager.shared.stop() + result(nil) + + default: + result(FlutterMethodNotImplemented) + } +} + GeneratedPluginRegistrant.register(with: self) SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback(registerPlugins) diff --git a/ui/flutter/ios/Runner/Info.plist b/ui/flutter/ios/Runner/Info.plist index 1be09f52..6e7c20c7 100644 --- a/ui/flutter/ios/Runner/Info.plist +++ b/ui/flutter/ios/Runner/Info.plist @@ -106,5 +106,15 @@ LSSupportsOpeningDocumentsInPlace + UIBackgroundModes + + location + + NSLocationWhenInUseUsageDescription + Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. + NSLocationAlwaysAndWhenInUseUsageDescription + Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. + NSLocationAlwaysUsageDescription + Please go to Settings and change the location permission to "Always" so that the app can continue running in the background. \ No newline at end of file diff --git a/ui/flutter/ios/Runner/LocationKeepAliveManager.swift b/ui/flutter/ios/Runner/LocationKeepAliveManager.swift new file mode 100644 index 00000000..33f2b047 --- /dev/null +++ b/ui/flutter/ios/Runner/LocationKeepAliveManager.swift @@ -0,0 +1,126 @@ +import Foundation +import CoreLocation + +final class LocationKeepAliveManager: NSObject, CLLocationManagerDelegate { + + static let shared = LocationKeepAliveManager() + + private let locationManager = CLLocationManager() + + private(set) var isRunning = false + + private var permissionCompletion: ((Bool) -> Void)? + + override init() { + super.init() + + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyThreeKilometers + locationManager.distanceFilter = kCLDistanceFilterNone + locationManager.pausesLocationUpdatesAutomatically = false + + if #available(iOS 9.0, *) { + locationManager.allowsBackgroundLocationUpdates = true + } + + if #available(iOS 14.0, *) { + locationManager.showsBackgroundLocationIndicator = true + } + } + + func requestPermission(completion: @escaping (Bool) -> Void) { + let status = CLLocationManager.authorizationStatus() + + switch status { + case .authorizedAlways: + completion(true) + + case .authorizedWhenInUse: + completion(false) + + case .denied, .restricted: + completion(false) + + case .notDetermined: + permissionCompletion = completion + locationManager.requestAlwaysAuthorization() + + @unknown default: + completion(false) + } + } + + + func start() { + guard !isRunning else { + return + } + + let status = CLLocationManager.authorizationStatus() + + guard status == .authorizedAlways else { + return + } + + if #available(iOS 9.0, *) { + locationManager.allowsBackgroundLocationUpdates = true + } + + locationManager.startUpdatingLocation() + isRunning = true + } + + func stop() { + locationManager.stopUpdatingLocation() + isRunning = false + } + + private func handleAuthorizationStatus( + _ status: CLAuthorizationStatus + ) { + switch status { + case .authorizedAlways: + permissionCompletion?(true) + permissionCompletion = nil + + case .authorizedWhenInUse, + .denied, + .restricted: + permissionCompletion?(false) + permissionCompletion = nil + + case .notDetermined: + break + + @unknown default: + permissionCompletion?(false) + permissionCompletion = nil + } + } + + @available(iOS 14.0, *) + func locationManagerDidChangeAuthorization( + _ manager: CLLocationManager + ) { + handleAuthorizationStatus(manager.authorizationStatus) + } + + func locationManager( + _ manager: CLLocationManager, + didChangeAuthorization status: CLAuthorizationStatus + ) { + handleAuthorizationStatus(status) + } + + func locationManager( + _ manager: CLLocationManager, + didUpdateLocations locations: [CLLocation] + ) { + } + + func locationManager( + _ manager: CLLocationManager, + didFailWithError error: Error + ) { + } +} \ No newline at end of file diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index aea13d03..80f95147 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -99,6 +99,17 @@ void init(String network, String address, String apiToken) { _client = _Client(network, address, apiToken); } +/// Used by LocationKeepAliveCoordinator to reconcile keep-alive +void Function()? _onTaskChanged; + +void setTaskChangedListener(void Function() listener) { + _onTaskChanged = listener; +} + +void _notifyTaskChanged() { + _onTaskChanged?.call(); +} + Future _parse( Future Function() fetch, T Function(dynamic json)? fromJsonT, @@ -130,15 +141,19 @@ Future resolve(ResolveTask resolveTask) async { } Future createTask(CreateTask createTask) async { - return _parse( + final result = await _parse( () => _client.dio.post("api/v1/tasks", data: createTask), (data) => data as String); + _notifyTaskChanged(); + return result; } Future> createTaskBatch(CreateTaskBatch createTaskBatch) async { - return _parse>( + final result = await _parse>( () => _client.dio.post("api/v1/tasks/batch", data: createTaskBatch), (data) => (data as List).map((e) => e as String).toList()); + _notifyTaskChanged(); + return result; } Future patchTask(String id, ResolveTask patchTask) async { @@ -154,41 +169,47 @@ Future> getTasks(List statuses) async { } Future pauseTask(String id) async { - return _parse(() => _client.dio.put("api/v1/tasks/$id/pause"), null); + await _parse(() => _client.dio.put("api/v1/tasks/$id/pause"), null); + _notifyTaskChanged(); } Future continueTask(String id) async { - return _parse(() => _client.dio.put("api/v1/tasks/$id/continue"), null); + await _parse(() => _client.dio.put("api/v1/tasks/$id/continue"), null); + _notifyTaskChanged(); } Future pauseAllTasks(List? ids) async { - return _parse( + await _parse( () => _client.dio.put("api/v1/tasks/pause", queryParameters: { "id": ids, }), null); + _notifyTaskChanged(); } Future continueAllTasks(List? ids) async { - return _parse( + await _parse( () => _client.dio.put("api/v1/tasks/continue", queryParameters: { "id": ids, }), null); + _notifyTaskChanged(); } Future deleteTask(String id, bool force) async { - return _parse( + await _parse( () => _client.dio.delete("api/v1/tasks/$id?force=$force"), null); + _notifyTaskChanged(); } Future deleteTasks(List? ids, bool force) async { - return _parse( + await _parse( () => _client.dio.delete("api/v1/tasks", queryParameters: { "id": ids, "force": force, }), null); + _notifyTaskChanged(); } Future getConfig() async { diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index 69f02027..f0769702 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -113,6 +113,7 @@ class ExtraConfig { bool notifyWhenNewVersion; bool autoStartTasks; bool desktopNotification; + bool backgroundLocationKeepAlive; List downloadCategories; ExtraConfigBt bt = ExtraConfigBt(); @@ -127,6 +128,7 @@ class ExtraConfig { this.notifyWhenNewVersion = true, this.autoStartTasks = false, this.desktopNotification = true, + this.backgroundLocationKeepAlive = false, this.downloadCategories = const [], }); diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 8284efed..9ed7a55b 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -111,6 +111,8 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, autoStartTasks: json['autoStartTasks'] as bool? ?? false, desktopNotification: json['desktopNotification'] as bool? ?? true, + backgroundLocationKeepAlive: + json['backgroundLocationKeepAlive'] as bool? ?? false, downloadCategories: (json['downloadCategories'] as List?) ?.map((e) => DownloadCategory.fromJson(e as Map)) .toList() ?? @@ -130,6 +132,7 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => 'notifyWhenNewVersion': instance.notifyWhenNewVersion, 'autoStartTasks': instance.autoStartTasks, 'desktopNotification': instance.desktopNotification, + 'backgroundLocationKeepAlive': instance.backgroundLocationKeepAlive, 'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(), 'bt': instance.bt.toJson(), diff --git a/ui/flutter/lib/app/modules/setting/views/setting_view.dart b/ui/flutter/lib/app/modules/setting/views/setting_view.dart index 7644e9fa..fb9c3ce9 100644 --- a/ui/flutter/lib/app/modules/setting/views/setting_view.dart +++ b/ui/flutter/lib/app/modules/setting/views/setting_view.dart @@ -32,6 +32,8 @@ import '../../../views/outlined_button_loading.dart'; import '../../../views/text_button_loading.dart'; import '../../app/controllers/app_controller.dart'; import '../controllers/setting_controller.dart'; +import '../../../services/location_keep_alive.dart'; +import '../../../services/location_keep_alive_coordinator.dart'; const _padding = SizedBox(height: 10); final _divider = const Divider().paddingOnly(left: 10, right: 10); @@ -521,6 +523,44 @@ class SettingView extends GetView { ); }); + final buildBackgroundLocationKeepAlive = !Platform.isIOS + ? () => const SizedBox.shrink() + : _buildConfigItem( + 'backgroundLocationKeepAlive', + () { + return appController + .downloaderConfig.value.extra.backgroundLocationKeepAlive + ? 'on'.tr + : 'off'.tr; + }, + (Key key) { + return Container( + alignment: Alignment.centerLeft, + child: Switch( + value: appController + .downloaderConfig.value.extra.backgroundLocationKeepAlive, + onChanged: (bool value) async { + if (value) { + final authorized = + await LocationKeepAlive.requestPermission(); + + if (!authorized) { + return; + } + } + + appController.downloaderConfig.update((val) { + val!.extra.backgroundLocationKeepAlive = value; + }); + + await debounceSave(); + await Get.find().reconcile(); + }, + ), + ); + }, + ); + // http config items start final httpConfig = downloaderCfg.value.protocolConfig.http; final buildHttpUa = @@ -1748,6 +1788,7 @@ class SettingView extends GetView { buildAutoStartup(), buildMenubarMode(), buildDesktopNotification(), + buildBackgroundLocationKeepAlive(), ]), )), Text('archives'.tr), diff --git a/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart b/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart index 19c240ee..06410cae 100644 --- a/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart +++ b/ui/flutter/lib/app/modules/task/controllers/task_list_controller.dart @@ -50,4 +50,5 @@ abstract class TaskListController extends GetxController { tasks.sort(compare); this.tasks.value = tasks; } + } diff --git a/ui/flutter/lib/app/services/location_keep_alive.dart b/ui/flutter/lib/app/services/location_keep_alive.dart new file mode 100644 index 00000000..8df0aed8 --- /dev/null +++ b/ui/flutter/lib/app/services/location_keep_alive.dart @@ -0,0 +1,32 @@ +//for ios +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class LocationKeepAlive { + static const _channel = MethodChannel('gopeed/location_keep_alive'); + + static Future requestPermission() async { + if (!Platform.isIOS) return false; + + try { + final result = await _channel.invokeMethod('requestPermission'); + + return result ?? false; + } catch (_) { + return false; + } + } + + static Future start() async { + if (!Platform.isIOS) return; + + await _channel.invokeMethod('start'); + } + + static Future stop() async { + if (!Platform.isIOS) return; + + await _channel.invokeMethod('stop'); + } +} diff --git a/ui/flutter/lib/app/services/location_keep_alive_coordinator.dart b/ui/flutter/lib/app/services/location_keep_alive_coordinator.dart new file mode 100644 index 00000000..e0db3e12 --- /dev/null +++ b/ui/flutter/lib/app/services/location_keep_alive_coordinator.dart @@ -0,0 +1,75 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import '../../../api/api.dart' as api; +import '../../../api/model/task.dart'; +import '../modules/app/controllers/app_controller.dart'; +import 'location_keep_alive.dart'; + +class LocationKeepAliveCoordinator extends GetxService { + Timer? _pollTimer; + bool _reconciling = false; + + /// Overridable for testing, defaults to reading from AppController. + bool Function() keepAliveEnabledProvider = () => Get.find() + .downloaderConfig + .value + .extra + .backgroundLocationKeepAlive; + + /// Overridable for testing so polling tests don't need to wait 10s. + Duration pollInterval = const Duration(seconds: 10); + + bool get _keepAliveEnabled => keepAliveEnabledProvider(); + + @override + void onInit() { + super.onInit(); + api.setTaskChangedListener(() { + reconcile(); + }); + } + + Future _fetchGlobalRunningCount() async { + try { + final tasks = await api.getTasks([Status.running]); + return tasks.length; + } catch (_) { + return -1; + } + } + + Future reconcile() async { + if (_reconciling) return; + _reconciling = true; + try { + if (!_keepAliveEnabled) { + await LocationKeepAlive.stop(); + return; + } + final count = await _fetchGlobalRunningCount(); + if (count < 0) return; + if (count > 0) { + await LocationKeepAlive.start(); + } else { + await LocationKeepAlive.stop(); + } + } finally { + _reconciling = false; + } + } + + void startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(pollInterval, (_) => reconcile()); + } + + void stopPolling() { + _pollTimer?.cancel(); + } + + @override + void onClose() { + stopPolling(); + super.onClose(); + } +} \ No newline at end of file diff --git a/ui/flutter/lib/i18n/langs/en_us.dart b/ui/flutter/lib/i18n/langs/en_us.dart index 965805f6..8b0f4657 100644 --- a/ui/flutter/lib/i18n/langs/en_us.dart +++ b/ui/flutter/lib/i18n/langs/en_us.dart @@ -226,6 +226,7 @@ const enUS = { 'ed2kServerMetHint': 'Server.met URL or ed2k serverlist link, one per line', 'ed2kNodesDatHint': 'nodes.dat path or URL, one per line', 'desktopNotification': 'Desktop Notifications', + 'backgroundLocationKeepAlive': 'Background Download Keep Alive', 'notificationTaskDone': 'Task completed', 'notificationTaskError': 'Task error', }, diff --git a/ui/flutter/lib/i18n/langs/zh_cn.dart b/ui/flutter/lib/i18n/langs/zh_cn.dart index 53cd1a36..596d9a4a 100644 --- a/ui/flutter/lib/i18n/langs/zh_cn.dart +++ b/ui/flutter/lib/i18n/langs/zh_cn.dart @@ -221,6 +221,7 @@ const zhCN = { 'ed2kServerMetHint': '请输入 Server.met 地址或 ed2k 服务器列表链接,每行一条', 'ed2kNodesDatHint': '请输入 nodes.dat 本地路径或 URL,每行一条', 'desktopNotification': '桌面通知', + 'backgroundLocationKeepAlive': '常驻后台', 'notificationTaskDone': '任务完成', 'notificationTaskError': '任务失败', } diff --git a/ui/flutter/lib/main.dart b/ui/flutter/lib/main.dart index 17f6830d..3e8b9f6f 100644 --- a/ui/flutter/lib/main.dart +++ b/ui/flutter/lib/main.dart @@ -22,6 +22,7 @@ import 'util/package_info.dart'; import 'util/scheme_register/scheme_register.dart'; import 'util/updater.dart'; import 'util/util.dart'; +import 'app/services/location_keep_alive_coordinator.dart'; class StartupArgs { static const flagHidden = "hidden"; @@ -135,6 +136,17 @@ Future init(StartupArgs args) async { logger.w("auto-start tasks fail", e); } } + + // iOS location keep-alive coordinator + if (Util.isIOS()) { + try { + await Get.putAsync(() async => LocationKeepAliveCoordinator()); + await Get.find().reconcile(); + Get.find().startPolling(); + } catch (e) { + logger.e("location keep-alive coordinator init fail", e); + } + } () async { if (Util.isDesktop()) {