feat: Background download on iOS (#1442)

This commit is contained in:
Afterain authored and GitHub committed 2026-08-18 18:08:53 +08:00
1 parent 9d3221ebbb
commit 99924296ef
14 files changed
+383 -26

No files matched your search

+22 -18
View File
@@ -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 = "<group>";};
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -153,18 +155,19 @@
sourceTree = "<group>";
};
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 = "<group>";
};
@@ -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 */
+28
View File
@@ -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)
+10
View File
@@ -106,5 +106,15 @@
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>location</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Please go to Settings and change the location permission to "Always" so that the app can continue running in the background.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Please go to Settings and change the location permission to "Always" so that the app can continue running in the background.</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>Please go to Settings and change the location permission to "Always" so that the app can continue running in the background.</string>
</dict>
</plist>
@@ -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
) {
}
}
+29 -8
View File
@@ -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<T> _parse<T>(
Future<Response> Function() fetch,
T Function(dynamic json)? fromJsonT,
@@ -130,15 +141,19 @@ Future<ResolveResult> resolve(ResolveTask resolveTask) async {
}
Future<String> createTask(CreateTask createTask) async {
return _parse<String>(
final result = await _parse<String>(
() => _client.dio.post("api/v1/tasks", data: createTask),
(data) => data as String);
_notifyTaskChanged();
return result;
}
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) async {
return _parse<List<String>>(
final result = await _parse<List<String>>(
() => _client.dio.post("api/v1/tasks/batch", data: createTaskBatch),
(data) => (data as List).map((e) => e as String).toList());
_notifyTaskChanged();
return result;
}
Future<void> patchTask(String id, ResolveTask patchTask) async {
@@ -154,41 +169,47 @@ Future<List<Task>> getTasks(List<Status> statuses) async {
}
Future<void> 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<void> 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<void> pauseAllTasks(List<String>? ids) async {
return _parse(
await _parse(
() => _client.dio.put("api/v1/tasks/pause", queryParameters: {
"id": ids,
}),
null);
_notifyTaskChanged();
}
Future<void> continueAllTasks(List<String>? ids) async {
return _parse(
await _parse(
() => _client.dio.put("api/v1/tasks/continue", queryParameters: {
"id": ids,
}),
null);
_notifyTaskChanged();
}
Future<void> deleteTask(String id, bool force) async {
return _parse(
await _parse(
() => _client.dio.delete("api/v1/tasks/$id?force=$force"), null);
_notifyTaskChanged();
}
Future<void> deleteTasks(List<String>? ids, bool force) async {
return _parse(
await _parse(
() => _client.dio.delete("api/v1/tasks", queryParameters: {
"id": ids,
"force": force,
}),
null);
_notifyTaskChanged();
}
Future<DownloaderConfig> getConfig() async {
@@ -113,6 +113,7 @@ class ExtraConfig {
bool notifyWhenNewVersion;
bool autoStartTasks;
bool desktopNotification;
bool backgroundLocationKeepAlive;
List<DownloadCategory> 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 [],
});
@@ -111,6 +111,8 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
@@ -130,6 +132,7 @@ Map<String, dynamic> _$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(),
@@ -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<SettingController> {
);
});
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<LocationKeepAliveCoordinator>().reconcile();
},
),
);
},
);
// http config items start
final httpConfig = downloaderCfg.value.protocolConfig.http;
final buildHttpUa =
@@ -1748,6 +1788,7 @@ class SettingView extends GetView<SettingController> {
buildAutoStartup(),
buildMenubarMode(),
buildDesktopNotification(),
buildBackgroundLocationKeepAlive(),
]),
)),
Text('archives'.tr),
@@ -50,4 +50,5 @@ abstract class TaskListController extends GetxController {
tasks.sort(compare);
this.tasks.value = tasks;
}
}
@@ -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<bool> requestPermission() async {
if (!Platform.isIOS) return false;
try {
final result = await _channel.invokeMethod<bool>('requestPermission');
return result ?? false;
} catch (_) {
return false;
}
}
static Future<void> start() async {
if (!Platform.isIOS) return;
await _channel.invokeMethod('start');
}
static Future<void> stop() async {
if (!Platform.isIOS) return;
await _channel.invokeMethod('stop');
}
}
@@ -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<AppController>()
.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<int> _fetchGlobalRunningCount() async {
try {
final tasks = await api.getTasks([Status.running]);
return tasks.length;
} catch (_) {
return -1;
}
}
Future<void> 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();
}
}
+1
View File
@@ -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',
},
+1
View File
@@ -221,6 +221,7 @@ const zhCN = {
'ed2kServerMetHint': '请输入 Server.met 地址或 ed2k 服务器列表链接,每行一条',
'ed2kNodesDatHint': '请输入 nodes.dat 本地路径或 URL,每行一条',
'desktopNotification': '桌面通知',
'backgroundLocationKeepAlive': '常驻后台',
'notificationTaskDone': '任务完成',
'notificationTaskError': '任务失败',
}
+12
View File
@@ -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<void> 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<LocationKeepAliveCoordinator>().reconcile();
Get.find<LocationKeepAliveCoordinator>().startPolling();
} catch (e) {
logger.e("location keep-alive coordinator init fail", e);
}
}
() async {
if (Util.isDesktop()) {