diff --git a/bind/mobile/main.go b/bind/mobile/main.go index 741335c7..380671b3 100644 --- a/bind/mobile/main.go +++ b/bind/mobile/main.go @@ -7,6 +7,7 @@ import ( nativebridge "github.com/GopeedLab/gopeed/bind/native" "github.com/GopeedLab/gopeed/internal/webview/rpcprovider" + goapi "github.com/GopeedLab/gopeed/pkg/api" "github.com/GopeedLab/gopeed/pkg/rest" "github.com/GopeedLab/gopeed/pkg/rest/model" ) @@ -90,3 +91,14 @@ func applyWebViewProvider(config *model.StartConfig) { } config.WebViewProvider = rpcprovider.New(*config.WebViewRPCConfig) } + +func LiveActivityTaskEventMask() int64 { + return int64( + goapi.TaskEventDone | + goapi.TaskEventError | + goapi.TaskEventStart | + goapi.TaskEventProgress | + goapi.TaskEventPause | + goapi.TaskEventDelete, + ) +} diff --git a/pkg/api/events.go b/pkg/api/events.go index fa2e416f..ac973440 100644 --- a/pkg/api/events.go +++ b/pkg/api/events.go @@ -10,8 +10,15 @@ import ( type TaskEventMask uint64 const ( + // Keep these first two unchanged because Flutter already uses + // bit 0 for done and bit 1 for error. TaskEventDone TaskEventMask = 1 << iota TaskEventError + + TaskEventStart + TaskEventProgress + TaskEventPause + TaskEventDelete ) type TaskEvent struct { @@ -42,11 +49,26 @@ func (s *Service) emitTaskEvent(event *download.Event) { } var mask TaskEventMask + switch event.Key { case download.EventKeyDone: mask = TaskEventDone + case download.EventKeyError: mask = TaskEventError + + case download.EventKeyStart: + mask = TaskEventStart + + case download.EventKeyProgress: + mask = TaskEventProgress + + case download.EventKeyPause: + mask = TaskEventPause + + case download.EventKeyDelete: + mask = TaskEventDelete + default: return } diff --git a/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift b/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift new file mode 100644 index 00000000..3d353cd9 --- /dev/null +++ b/ui/flutter/ios/GopeedLiveActivity/GopeedLiveActivityWidget.swift @@ -0,0 +1,236 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +@main +struct GopeedLiveActivityBundle: WidgetBundle { + var body: some Widget { + GopeedLiveActivityWidget() + } +} + +struct GopeedLiveActivityWidget: Widget { + + var body: some WidgetConfiguration { + + ActivityConfiguration( + for: GopeedDownloadAttributes.self + ) { context in + + // MARK: Lock Screen / notification banner + + VStack(spacing: 10) { + + HStack { + + Image(systemName: statusIcon(context.state.status)) + .font(.title3) + + VStack(alignment: .leading, spacing: 2) { + + Text(context.attributes.fileName) + .font(.headline) + .lineLimit(1) + + Text(statusText(context.state.status)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Text( + "\(Int(context.state.progress * 100))%" + ) + .font(.headline) + .monospacedDigit() + } + + progressView(context) + + HStack { + + Text( + "\(formatBytes(context.state.downloaded)) / \(formatBytes(context.state.total))" + ) + + Spacer() + + if context.state.speed > 0 { + Text( + "\(formatBytes(context.state.speed))/s" + ) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .padding() + .activityBackgroundTint(.black.opacity(0.85)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + + DynamicIsland { + + // MARK: Expanded Dynamic Island + + DynamicIslandExpandedRegion(.leading) { + Image( + systemName: statusIcon( + context.state.status + ) + ) + .font(.title3) + } + + DynamicIslandExpandedRegion(.center) { + Text(context.attributes.fileName) + .lineLimit(1) + .font(.headline) + } + + DynamicIslandExpandedRegion(.trailing) { + Text( + "\(Int(context.state.progress * 100))%" + ) + .monospacedDigit() + .font(.headline) + } + + DynamicIslandExpandedRegion(.bottom) { + + VStack(spacing: 6) { + + progressView(context) + + HStack { + + Text( + "\(formatBytes(context.state.downloaded)) / \(formatBytes(context.state.total))" + ) + + Spacer() + + if context.state.speed > 0 { + Text( + "\(formatBytes(context.state.speed))/s" + ) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + } compactLeading: { + + Image( + systemName: statusIcon( + context.state.status + ) + ) + + } compactTrailing: { + + Text( + "\(Int(context.state.progress * 100))%" + ) + .monospacedDigit() + + } minimal: { + + Image(systemName: "arrow.down") + } + } + } + + + // MARK: - Progress bar + + @ViewBuilder + private func progressView( + _ context: + ActivityViewContext + ) -> some View { + + if context.state.usesEstimatedProgress, + context.state.estimatedEnd > + context.state.estimatedStart { + + ProgressView( + timerInterval: + context.state.estimatedStart + ... + context.state.estimatedEnd, + countsDown: false + ) + + } else { + + ProgressView( + value: context.state.progress, + total: 1.0 + ) + } + } + + + // MARK: - Formatting + + private func formatBytes( + _ bytes: Int64 + ) -> String { + + guard bytes > 0 else { + return "0 B" + } + + return ByteCountFormatter.string( + fromByteCount: bytes, + countStyle: .file + ) + } + + + private func statusIcon( + _ status: String + ) -> String { + + switch status { + + case "done": + return "checkmark.circle.fill" + + case "error": + return "exclamationmark.triangle.fill" + + case "pause", "paused": + return "pause.circle.fill" + + default: + return "arrow.down.circle.fill" + } + } + + + private func statusText( + _ status: String + ) -> String { + + switch status { + + case "done": + return "Download complete" + + case "error": + return "Download failed" + + case "pause", "paused": + return "Paused" + + default: + return "Downloading" + } + } +} diff --git a/ui/flutter/ios/GopeedLiveActivity/Info.plist b/ui/flutter/ios/GopeedLiveActivity/Info.plist new file mode 100644 index 00000000..3d6cefc8 --- /dev/null +++ b/ui/flutter/ios/GopeedLiveActivity/Info.plist @@ -0,0 +1,24 @@ + + + + + + + CFBundleDisplayName + Gopeed + + CFBundleShortVersionString + $(MARKETING_VERSION) + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + + diff --git a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj index faa07d38..677ed142 100644 --- a/ui/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/ui/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -7,13 +7,19 @@ objects = { /* Begin PBXBuildFile section */ + 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */; }; 0C585CBA2D41E28900FF2EC0 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */; }; 0C585CBD2D41E28900FF2EC0 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 0C585CBC2D41E28900FF2EC0 /* Base */; }; 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 1045ACE898D35C0D8D8A993E /* GopeedDownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */; }; 6085D504396791024CEC496B /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8823E31DA135C410E362683E /* Pods_RunnerTests.framework */; }; + 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -30,6 +36,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 03B416C78831516F6591BD24 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = FFA6D07C1B1CB56543DE4B67; + remoteInfo = GopeedLiveActivityExtension; + }; 0C585CBF2D41E28900FF2EC0 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -54,6 +67,7 @@ dstSubfolderSpec = 13; files = ( 0C585CC12D41E28900FF2EC0 /* ShareExtension.appex in Embed Foundation Extensions */, + 3EF29839EA13551EB8144269 /* GopeedLiveActivityExtension.appex in Embed Foundation Extensions */, ); name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; @@ -72,6 +86,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedDownloadAttributes.swift; sourceTree = ""; }; 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0C585CB92D41E28900FF2EC0 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; 0C585CBC2D41E28900FF2EC0 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = ""; }; @@ -85,11 +100,15 @@ 3217CFC346202B3E2A8C52DD /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityWidget.swift; sourceTree = ""; }; + 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedContinuedProcessingManager.swift; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 52A13633B52EDC9D3F479DFD /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 55356FCC7E573C3FEEC2DF4D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 5B918B8D5281BAE2DEA6BE3C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = GopeedLiveActivityExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78EE8538B1DF04DAF73E75D3 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = ""; }; 78F0D48FED63C04B9A3FFDE4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; @@ -112,6 +131,7 @@ D554135997A5C7A2C62E1B64 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; D081C25B294826C0006EB10B /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; D0E623A729482D030001185E /* Libgopeed.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = Libgopeed.xcframework; path = Frameworks/Libgopeed.xcframework; sourceTree = ""; }; + EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GopeedLiveActivityManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -123,6 +143,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2134C1710299C602C39EE9D6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -194,6 +221,16 @@ name = Frameworks; sourceTree = ""; }; + 5FB22DCA9F2EA11B7C1F1A22 /* GopeedLiveActivity */ = { + isa = PBXGroup; + children = ( + 35C9F8DE4B991051758F2BE1 /* GopeedLiveActivityWidget.swift */, + 52A13633B52EDC9D3F479DFD /* Info.plist */, + ); + name = GopeedLiveActivity; + path = GopeedLiveActivity; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -215,6 +252,7 @@ 331C8082294A63A400263BE5 /* RunnerTests */, 2B6C6F082D376286EF3D5FA3 /* Pods */, 44BA5D7C75A3E4957F6560C7 /* Frameworks */, + 5FB22DCA9F2EA11B7C1F1A22 /* GopeedLiveActivity */, ); sourceTree = ""; }; @@ -224,6 +262,7 @@ 97C146EE1CF9000F007C117D /* Runner.app */, 0C585CB72D41E28900FF2EC0 /* ShareExtension.appex */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */, ); name = Products; sourceTree = ""; @@ -244,6 +283,9 @@ A20000000000000000000003 /* GopeedTaskEventForwarder.h */, A20000000000000000000002 /* GopeedTaskEventForwarder.m */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 09AC32C4BF90EAC54D048584 /* GopeedDownloadAttributes.swift */, + EB8C142A455C80367DDD68AE /* GopeedLiveActivityManager.swift */, + 3879B6DB5398A19281BA5F8F /* GopeedContinuedProcessingManager.swift */, ); path = Runner; sourceTree = ""; @@ -306,12 +348,30 @@ ); dependencies = ( 0C585CC02D41E28900FF2EC0 /* PBXTargetDependency */, + FBEBBD3469D2B152FDD0B424 /* PBXTargetDependency */, ); name = Runner; productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = DAF208923F589C87CBB49D56 /* Build configuration list for PBXNativeTarget "GopeedLiveActivityExtension" */; + buildPhases = ( + 1A1326FD8C4C86ED5A529666 /* Sources */, + 2134C1710299C602C39EE9D6 /* Frameworks */, + 9AB02E797D34C8878FDBED22 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GopeedLiveActivityExtension; + productName = GopeedLiveActivityExtension; + productReference = 76ED41E0163DFA97F018D1AC /* GopeedLiveActivityExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -351,6 +411,7 @@ 97C146ED1CF9000F007C117D /* Runner */, 0C585CB62D41E28900FF2EC0 /* ShareExtension */, 331C8080294A63A400263BE5 /* RunnerTests */, + FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */, ); }; /* End PBXProject section */ @@ -382,6 +443,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 9AB02E797D34C8878FDBED22 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -510,6 +578,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1A1326FD8C4C86ED5A529666 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6243BE8F995F5284DBDD7DE3 /* GopeedLiveActivityWidget.swift in Sources */, + 1045ACE898D35C0D8D8A993E /* GopeedDownloadAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -527,6 +604,9 @@ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, A10000000000000000000001 /* LocationKeepAliveManager.swift in Sources */, A20000000000000000000001 /* GopeedTaskEventForwarder.m in Sources */, + 1E29080079AAFD1CF2C71262 /* GopeedDownloadAttributes.swift in Sources */, + 032447D610CFAF8A75642212 /* GopeedLiveActivityManager.swift in Sources */, + 5BF69191A426106A38B62F9F /* GopeedContinuedProcessingManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -543,6 +623,12 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + FBEBBD3469D2B152FDD0B424 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = GopeedLiveActivityExtension; + target = FFA6D07C1B1CB56543DE4B67 /* GopeedLiveActivityExtension */; + targetProxy = 03B416C78831516F6591BD24 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -774,6 +860,61 @@ }; name = Profile; }; + 6CADF5E48DB3AF5F00998F2A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 91F84B2C36416D1F9C500144 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -930,6 +1071,34 @@ }; name = Release; }; + ABCC87B2280A98A3C9E482CF /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB31CF90195004384FC /* Generated.xcconfig */; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = GopeedLiveActivity/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Gopeed; + IPHONEOS_DEPLOYMENT_TARGET = 16.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = com.gopeed.gopeed.GopeedLiveActivityExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -973,6 +1142,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + DAF208923F589C87CBB49D56 /* Build configuration list for PBXNativeTarget "GopeedLiveActivityExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 6CADF5E48DB3AF5F00998F2A /* Release */, + 91F84B2C36416D1F9C500144 /* Debug */, + ABCC87B2280A98A3C9E482CF /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; diff --git a/ui/flutter/ios/Runner/AppDelegate.swift b/ui/flutter/ios/Runner/AppDelegate.swift index e99ee6de..997da44a 100644 --- a/ui/flutter/ios/Runner/AppDelegate.swift +++ b/ui/flutter/ios/Runner/AppDelegate.swift @@ -63,8 +63,19 @@ import UIKit GopeedInvokeAsyncWithResult(method, path, query, body, requestID, result) case "subscribeTaskEvents": let arguments = call.arguments as? [String: Any] - let mask = (arguments?["mask"] as? NSNumber)?.int64Value ?? 0 - GopeedSubscribeTaskEventsWithForwarder(mask, mask == 0 ? nil : taskEventForwarder) + let flutterMask = + (arguments?["mask"] as? NSNumber)?.int64Value ?? 0 + + let liveActivityMask = + LibgopeedLiveActivityTaskEventMask() + + let combinedMask = + flutterMask | liveActivityMask + + GopeedSubscribeTaskEventsWithForwarder( + combinedMask, + taskEventForwarder + ) result(nil) default: result(FlutterMethodNotImplemented) @@ -91,5 +102,55 @@ import UIKit result(FlutterMethodNotImplemented) } } + let continuedProcessingChannel = + FlutterMethodChannel( + name: "gopeed/continued_processing", + binaryMessenger: messenger + ) + + continuedProcessingChannel + .setMethodCallHandler { call, result in + + switch call.method { + + case "isSupported": + + if #available(iOS 26.0, *) { + result(true) + } else { + result(false) + } + + case "setEnabled": + + let arguments = + call.arguments + as? [String: Any] + + let enabled = + arguments?["enabled"] + as? Bool + ?? false + + if #available(iOS 26.0, *) { + + GopeedContinuedProcessingManager + .shared + .setEnabled(enabled) { + success in + result(success) + } + + } else { + + result(!enabled) + } + + default: + result( + FlutterMethodNotImplemented + ) + } + } } } diff --git a/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift new file mode 100644 index 00000000..7ef2b852 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedContinuedProcessingManager.swift @@ -0,0 +1,1117 @@ +import BackgroundTasks +import Foundation +import Libgopeed +import UIKit + +@available(iOS 26.0, *) +@objcMembers +final class GopeedContinuedProcessingManager: NSObject { + + static let shared = GopeedContinuedProcessingManager() + + // MARK: - Worker / state synchronization + + private let workerQueue = DispatchQueue( + label: "com.gopeed.continued-processing", + qos: .userInitiated + ) + + private let stateLock = NSLock() + private var enabledSnapshot = false + private var handledTaskIDs = Set() + + // MARK: - BGCPT state + + private var registeredIdentifiers = Set() + private var pendingTaskIDs = Set() + private struct ExpiringTaskState { + let identifier: String + let generation: UUID + } + + private var expiringTasks: + [String: ExpiringTaskState] = [:] + private var taskGenerations: [String: UUID] = [:] + + private var activeTasks: + [String: BGContinuedProcessingTask] = [:] + + private var taskIdentifiers: + [String: String] = [:] + + private var taskNames: + [String: String] = [:] + + private var lastProgressUpdate: + [String: Date] = [:] + + private var lastTitleUpdate: + [String: Date] = [:] + + private let minimumProgressUpdateInterval: + TimeInterval = 1.0 + + private let minimumTitleUpdateInterval: + TimeInterval = 1.0 + + private override init() { + super.init() + } + + + // MARK: - Synchronization helpers + + private func currentEnabledSnapshot() -> Bool { + stateLock.lock() + let value = enabledSnapshot + stateLock.unlock() + + return value + } + + private func setEnabledSnapshot( + _ enabled: Bool + ) { + stateLock.lock() + enabledSnapshot = enabled + stateLock.unlock() + } + + private func setHandledSnapshot( + taskID: String, + handled: Bool + ) { + stateLock.lock() + + if handled { + handledTaskIDs.insert(taskID) + } else { + handledTaskIDs.remove(taskID) + } + + stateLock.unlock() + } + + private func handledTaskIDsSnapshot() -> [String] { + stateLock.lock() + let ids = Array(handledTaskIDs) + stateLock.unlock() + + return ids + } + + private func setTaskOwnership( + taskID: String, + handled: Bool + ) { + setHandledSnapshot( + taskID: taskID, + handled: handled + ) + + // This is a second layer of protection in addition to the + // Objective-C forwarder. If Continued Processing owns a task, + // the custom ActivityKit manager must not create a second + // Live Activity for it. + GopeedLiveActivityManager.shared + .setTaskSuppressed( + handled, + taskID: taskID + ) + } + + + // MARK: - Setting + + func setEnabled( + _ enabled: Bool, + completion: @escaping (Bool) -> Void + ) { + workerQueue.async { [weak self] in + guard let self else { + DispatchQueue.main.async { + completion(false) + } + return + } + + self.setEnabledSnapshot(enabled) + + if !enabled { + self.stopAllContinuedTasks() + } + + print( + "ContinuedProcessing: enabled =", + enabled + ) + + DispatchQueue.main.async { + completion(true) + } + } + } + + + // MARK: - Event entry point + + func handleTaskEventPayload( + _ payload: String + ) { + + guard currentEnabledSnapshot() else { + return + } + + // Pre-mark task.start so the Objective-C forwarder can + // immediately suppress the custom ActivityKit Live Activity. + if + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject( + with: data + ) as? [String: Any], + let type = json["type"] as? String, + type == "task.start", + let taskID = json["taskId"] as? String + { + setTaskOwnership( + taskID: taskID, + handled: true + ) + } + + // IMPORTANT: asynchronous, so Gopeed's event thread + // is never blocked by BGCPT bookkeeping. + workerQueue.async { [weak self] in + self?.handleTaskEventPayloadOnWorker( + payload + ) + } + } + + private func handleTaskEventPayloadOnWorker( + _ payload: String + ) { + + guard currentEnabledSnapshot() else { + return + } + + guard + let data = payload.data( + using: .utf8 + ), + let json = + try? JSONSerialization + .jsonObject( + with: data + ) as? [String: Any], + let type = + json["type"] as? String, + let taskID = + json["taskId"] as? String + else { + print( + "ContinuedProcessing: invalid event" + ) + return + } + + let name = + json["name"] as? String + ?? "Download" + + switch type { + + case "task.start": + beginTask( + taskID: taskID, + name: name + ) + + case "task.progress": + if let generation = + taskGenerations[taskID] { + updateProgress( + taskID: taskID, + generation: generation, + force: false + ) + } + + case "task.pause": + if let expiring = + expiringTasks[taskID] { + finishExpirationCleanup( + taskID: taskID, + identifier: expiring.identifier, + generation: expiring.generation + ) + } else { + finishTask( + taskID: taskID, + success: true, + finalSubtitle: "Paused" + ) + } + + case "task.done": + finishTask( + taskID: taskID, + success: true, + finalSubtitle: + "Download complete" + ) + + case "task.error": + finishTask( + taskID: taskID, + success: false, + finalSubtitle: + "Download failed" + ) + + case "task.delete": + finishTask( + taskID: taskID, + success: true, + finalSubtitle: + "Download removed" + ) + + default: + break + } + } + + + // MARK: - Used by Objective-C forwarder + + @objc(isHandlingTaskId:) + func isHandlingTaskId( + _ taskID: String + ) -> Bool { + + // Do not wait for workerQueue here. The forwarder calls + // this for frequent progress events. + stateLock.lock() + let handled = handledTaskIDs.contains(taskID) + stateLock.unlock() + + return handled + } + + + // MARK: - Start BGCPT + + private func beginTask( + taskID: String, + name: String + ) { + + guard currentEnabledSnapshot() else { + setTaskOwnership( + taskID: taskID, + handled: false + ) + return + } + + guard + !pendingTaskIDs.contains(taskID), + activeTasks[taskID] == nil + else { + setTaskOwnership( + taskID: taskID, + handled: true + ) + return + } + + // BGCPT should originate from a foreground user action. + // This main-thread check happens only once per task start. + let appIsActive: Bool + + if Thread.isMainThread { + appIsActive = + UIApplication.shared + .applicationState == .active + } else { + appIsActive = + DispatchQueue.main.sync { + UIApplication.shared + .applicationState == .active + } + } + + guard appIsActive else { + print( + "ContinuedProcessing:", + "ignored non-foreground start", + taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + return + } + + let generation = UUID() + taskGenerations[taskID] = generation + + let identifier = + makeIdentifier( + taskID: taskID, + generation: generation + ) + + taskIdentifiers[taskID] = identifier + taskNames[taskID] = name + + let registered = + BGTaskScheduler.shared.register( + forTaskWithIdentifier: identifier, + using: nil + ) { [weak self] task in + guard + let self, + let continuedTask = + task as? BGContinuedProcessingTask + else { + task.setTaskCompleted( + success: false + ) + return + } + + self.workerQueue.async { + self.activateTask( + continuedTask, + taskID: taskID, + name: name, + identifier: identifier, + generation: generation + ) + } + } + + guard registered else { + print( + "ContinuedProcessing:", + "registration failed:", + identifier + ) + + if taskGenerations[taskID] == generation { + taskGenerations.removeValue( + forKey: taskID + ) + taskIdentifiers.removeValue( + forKey: taskID + ) + taskNames.removeValue( + forKey: taskID + ) + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + return + } + + registeredIdentifiers.insert(identifier) + + let request = + BGContinuedProcessingTaskRequest( + identifier: identifier, + title: name, + subtitle: "Preparing download…" + ) + + request.strategy = .fail + + pendingTaskIDs.insert(taskID) + + setTaskOwnership( + taskID: taskID, + handled: true + ) + + do { + try BGTaskScheduler.shared.submit( + request + ) + + print( + "ContinuedProcessing:", + "submitted:", + identifier + ) + } catch { + if taskGenerations[taskID] == generation { + pendingTaskIDs.remove(taskID) + taskGenerations.removeValue( + forKey: taskID + ) + + if taskIdentifiers[taskID] + == identifier { + taskIdentifiers.removeValue( + forKey: taskID + ) + } + + taskNames.removeValue( + forKey: taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + + print( + "ContinuedProcessing:", + "submission failed:", + error + ) + } + } + + + // MARK: - System launched task + + private func activateTask( + _ task: BGContinuedProcessingTask, + taskID: String, + name: String, + identifier: String, + generation: UUID + ) { + guard + currentEnabledSnapshot(), + taskGenerations[taskID] == generation, + pendingTaskIDs.contains(taskID), + taskIdentifiers[taskID] == identifier + else { + task.setTaskCompleted( + success: false + ) + return + } + + pendingTaskIDs.remove(taskID) + + activeTasks[taskID] = task + taskNames[taskID] = name + + setTaskOwnership( + taskID: taskID, + handled: true + ) + + task.progress.totalUnitCount = 100 + task.progress.completedUnitCount = 0 + + task.expirationHandler = { + [weak self, weak task] in + guard + let self, + let task + else { + return + } + + self.workerQueue.async { + self.handleExpiration( + task, + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + } + + print( + "ContinuedProcessing:", + "started:", + taskID + ) + + updateProgress( + taskID: taskID, + generation: generation, + force: true + ) + } + + + // MARK: - Async Gopeed invocation + + private func invokeGopeed( + method: String, + path: String, + query: String = "", + body: String = "", + completion: + @escaping (String?) -> Void + ) { + + GopeedInvokeAsyncNative( + method, + path, + query, + body + ) { [weak self] success, payload in + + guard let self else { + return + } + + self.workerQueue.async { + completion( + success + ? payload + : nil + ) + } + } + } + + + // MARK: - Progress + + private struct RuntimeStatus { + let status: String + let downloaded: Int64 + let total: Int64 + let speed: Int64 + } + + private func getRuntimeStatus( + taskID: String, + completion: + @escaping (RuntimeStatus?) -> Void + ) { + + invokeGopeed( + method: "GET", + path: + "/api/v1/tasks/\(taskID)/status" + ) { response in + + guard + let response, + let data = + response.data( + using: .utf8 + ), + let root = + try? JSONSerialization + .jsonObject( + with: data + ) as? [String: Any], + let code = + (root["code"] as? NSNumber)? + .intValue, + code == 0, + let body = + root["data"] + as? [String: Any] + else { + completion(nil) + return + } + + completion( + RuntimeStatus( + status: + body["status"] + as? String ?? "", + downloaded: + (body["downloaded"] + as? NSNumber)? + .int64Value ?? 0, + total: + (body["total"] + as? NSNumber)? + .int64Value ?? 0, + speed: + (body["speed"] + as? NSNumber)? + .int64Value ?? 0 + ) + ) + } + } + + private func updateProgress( + taskID: String, + generation: UUID, + force: Bool + ) { + + guard + taskGenerations[taskID] == generation, + activeTasks[taskID] != nil + else { + return + } + + let requestTime = Date() + + if !force, + let previous = + lastProgressUpdate[taskID], + requestTime.timeIntervalSince(previous) + < minimumProgressUpdateInterval { + + return + } + + // Mark before starting the async request so frequent + // task.progress events cannot create overlapping + // status requests. + lastProgressUpdate[taskID] = + requestTime + + getRuntimeStatus( + taskID: taskID + ) { [weak self] runtime in + + guard + let self, + let runtime, + self.taskGenerations[taskID] + == generation, + let task = + self.activeTasks[taskID] + else { + return + } + + let now = Date() + + let shouldUpdateTitle: Bool + + if force { + + shouldUpdateTitle = true + + } else if let previous = + self.lastTitleUpdate[ + taskID + ] { + + shouldUpdateTitle = + now.timeIntervalSince( + previous + ) + >= + self.minimumTitleUpdateInterval + + } else { + + shouldUpdateTitle = true + } + + let downloaded = + max( + runtime.downloaded, + 0 + ) + + let name = + self.taskNames[taskID] + ?? "Download" + + if runtime.total > 0 { + + let total = + max(runtime.total, 1) + + let completed = + min( + downloaded, + total + ) + + task.progress + .totalUnitCount = + total + + task.progress + .completedUnitCount = + completed + + if shouldUpdateTitle { + + let percent = + Int( + ( + Double( + completed + ) + / Double(total) + * 100.0 + ).rounded() + ) + + var subtitle = + "\(percent)% • " + + "\(self.formatBytes(completed)) / " + + "\(self.formatBytes(total))" + + if runtime.speed > 0 { + subtitle += + " • " + + "\(self.formatBytes(runtime.speed))/s" + } + + task.updateTitle( + name, + subtitle: + subtitle + ) + + self.lastTitleUpdate[ + taskID + ] = now + } + + } else { + + task.progress + .totalUnitCount = 100 + + task.progress + .completedUnitCount = 0 + + if shouldUpdateTitle { + + var subtitle = + self.formatBytes( + downloaded + ) + + if runtime.speed > 0 { + subtitle += + " • " + + "\(self.formatBytes(runtime.speed))/s" + } + + task.updateTitle( + name, + subtitle: + subtitle + ) + + self.lastTitleUpdate[ + taskID + ] = now + } + } + } + } + + // MARK: - Completion + + private func finishTask( + taskID: String, + success: Bool, + finalSubtitle: String + ) { + taskGenerations.removeValue( + forKey: taskID + ) + + expiringTasks.removeValue( + forKey: taskID + ) + + if let identifier = + taskIdentifiers[taskID] { + + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: + identifier + ) + } + + pendingTaskIDs.remove(taskID) + + if let task = + activeTasks.removeValue( + forKey: taskID + ) { + + if success, + finalSubtitle == + "Download complete", + task.progress + .totalUnitCount > 0 { + + task.progress + .completedUnitCount = + task.progress + .totalUnitCount + } + + task.updateTitle( + taskNames[taskID] + ?? "Download", + subtitle: + finalSubtitle + ) + + task.setTaskCompleted( + success: success + ) + } + + taskIdentifiers.removeValue( + forKey: taskID + ) + + taskNames.removeValue( + forKey: taskID + ) + + lastProgressUpdate.removeValue( + forKey: taskID + ) + + lastTitleUpdate.removeValue( + forKey: taskID + ) + + setTaskOwnership( + taskID: taskID, + handled: false + ) + + print( + "ContinuedProcessing:", + "finished:", + taskID, + "success:", + success + ) + } + + // MARK: - Expiration / system cancellation + + private func handleExpiration( + _ task: BGContinuedProcessingTask, + taskID: String, + identifier: String, + generation: UUID + ) { + guard + taskGenerations[taskID] == generation, + taskIdentifiers[taskID] == identifier + else { + task.setTaskCompleted( + success: false + ) + return + } + + taskGenerations.removeValue( + forKey: taskID + ) + + activeTasks.removeValue( + forKey: taskID + ) + + pendingTaskIDs.remove(taskID) + expiringTasks[taskID] = + ExpiringTaskState( + identifier: identifier, + generation: generation + ) + + lastProgressUpdate.removeValue( + forKey: taskID + ) + + lastTitleUpdate.removeValue( + forKey: taskID + ) + + // Keep ownership/suppression active until the pause request + // has been delivered. Otherwise progress events arriving in + // this small window can start a custom ActivityKit activity. + setTaskOwnership( + taskID: taskID, + handled: true + ) + + print( + "ContinuedProcessing:", + "expired/cancelled:", + taskID + ) + + task.setTaskCompleted( + success: false + ) + + invokeGopeed( + method: "PUT", + path: + "/api/v1/tasks/\(taskID)/pause" + ) { [weak self] _ in + self?.finishExpirationCleanup( + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + + // Defensive fallback in case the async bridge never calls + // back because the process is being suspended. + workerQueue.asyncAfter( + deadline: .now() + 2.0 + ) { [weak self] in + self?.finishExpirationCleanup( + taskID: taskID, + identifier: identifier, + generation: generation + ) + } + } + + private func finishExpirationCleanup( + taskID: String, + identifier: String, + generation: UUID + ) { + guard + let expiring = expiringTasks[taskID], + expiring.generation == generation, + expiring.identifier == identifier + else { + return + } + + expiringTasks.removeValue( + forKey: taskID + ) + + if taskIdentifiers[taskID] + == identifier { + taskIdentifiers.removeValue( + forKey: taskID + ) + + if taskGenerations[taskID] == nil { + taskNames.removeValue( + forKey: taskID + ) + } + } + + // Never clear ownership for a newer start of the same task ID. + if taskGenerations[taskID] == nil { + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + } + + + // MARK: - Disable all BGCPT tasks + + private func stopAllContinuedTasks() { + + let ownedTaskIDs = + Set( + handledTaskIDsSnapshot() + + Array(taskIdentifiers.keys) + + Array(pendingTaskIDs) + + Array(activeTasks.keys) + + Array(expiringTasks.keys) + ) + + for ( + taskID, + identifier + ) in taskIdentifiers { + + BGTaskScheduler.shared.cancel( + taskRequestWithIdentifier: + identifier + ) + + if let task = + activeTasks[taskID] { + + task.setTaskCompleted( + success: true + ) + } + } + + activeTasks.removeAll() + pendingTaskIDs.removeAll() + expiringTasks.removeAll() + taskGenerations.removeAll() + taskIdentifiers.removeAll() + taskNames.removeAll() + lastProgressUpdate.removeAll() + lastTitleUpdate.removeAll() + + for taskID in ownedTaskIDs { + setTaskOwnership( + taskID: taskID, + handled: false + ) + } + } + + + // MARK: - Helpers + + private func makeIdentifier( + taskID: String, + generation: UUID + ) -> String { + + let bundleID = + Bundle.main.bundleIdentifier + ?? "com.gopeed.gopeed" + + let safeID = + taskID.replacingOccurrences( + of: "[^A-Za-z0-9_-]", + with: "-", + options: + .regularExpression + ) + + return + "\(bundleID)" + + ".continuedDownload." + + safeID + + "." + + generation.uuidString.lowercased() + } + + private func formatBytes( + _ bytes: Int64 + ) -> String { + + return ByteCountFormatter.string( + fromByteCount: + max(bytes, 0), + countStyle: .file + ) + } +} diff --git a/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift b/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift new file mode 100644 index 00000000..a4a127b0 --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedDownloadAttributes.swift @@ -0,0 +1,28 @@ +import ActivityKit +import Foundation + +@available(iOS 16.2, *) +struct GopeedDownloadAttributes: ActivityAttributes { + + struct ContentState: Codable, Hashable { + + // Real confirmed progress. + var progress: Double + + var downloaded: Int64 + var total: Int64 + var speed: Int64 + + var status: String + + // Used by the self-moving estimated progress bar. + var estimatedStart: Date + var estimatedEnd: Date + var usesEstimatedProgress: Bool + } + + // Static properties. + var taskId: String + var fileName: String + var generation: String +} diff --git a/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift new file mode 100644 index 00000000..84f965ba --- /dev/null +++ b/ui/flutter/ios/Runner/GopeedLiveActivityManager.swift @@ -0,0 +1,1050 @@ +import ActivityKit +import Foundation +import Libgopeed +import UIKit + +@objcMembers +final class GopeedLiveActivityManager: NSObject { + + static let shared = GopeedLiveActivityManager() + + // Gopeed emits progress approximately every 350 ms. + // There is no reason to hit ActivityKit that frequently. + private let minimumUpdateInterval: TimeInterval = 2.0 + + private let stateLock = NSLock() + + private var lastUpdateTime: [String: Date] = [:] + private var suppressedTaskIDs = Set() + private var taskGenerations: [String: UUID] = [:] + private var activityCreationInProgress: + [String: UUID] = [:] + + private override init() { + super.init() + } + + + // MARK: - BGCPT coordination + + func setTaskSuppressed( + _ suppressed: Bool, + taskID: String + ) { + stateLock.lock() + + if suppressed { + suppressedTaskIDs.insert(taskID) + taskGenerations.removeValue( + forKey: taskID + ) + lastUpdateTime.removeValue( + forKey: taskID + ) + } else { + suppressedTaskIDs.remove(taskID) + } + + stateLock.unlock() + + if suppressed { + guard #available(iOS 16.2, *) else { + return + } + + Task { [weak self] in + guard let self else { + return + } + + // Suppression may have been removed again if BGCPT + // submission failed before this async cleanup runs. + guard self.isTaskSuppressed(taskID) else { + return + } + + await self.removeAllActivities( + taskID: taskID, + reason: "suppressed by continued processing" + ) + } + } + } + + private func isTaskSuppressed( + _ taskID: String + ) -> Bool { + stateLock.lock() + let suppressed = + suppressedTaskIDs.contains(taskID) + stateLock.unlock() + + return suppressed + } + + private func startGeneration( + taskID: String + ) -> UUID { + let generation = UUID() + + stateLock.lock() + taskGenerations[taskID] = generation + lastUpdateTime.removeValue( + forKey: taskID + ) + stateLock.unlock() + + return generation + } + + private func currentGeneration( + taskID: String + ) -> UUID? { + stateLock.lock() + let generation = + taskGenerations[taskID] + stateLock.unlock() + + return generation + } + + @discardableResult + private func invalidateGeneration( + taskID: String + ) -> UUID? { + stateLock.lock() + let generation = + taskGenerations.removeValue( + forKey: taskID + ) + lastUpdateTime.removeValue( + forKey: taskID + ) + stateLock.unlock() + + return generation + } + + private func isCurrentGeneration( + taskID: String, + generation: UUID + ) -> Bool { + stateLock.lock() + let isCurrent = + taskGenerations[taskID] + == generation + stateLock.unlock() + + return isCurrent + } + + private func beginActivityCreation( + taskID: String, + generation: UUID + ) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + + guard + !suppressedTaskIDs.contains(taskID), + taskGenerations[taskID] == generation + else { + return false + } + + if activityCreationInProgress[taskID] + == generation { + return false + } + + activityCreationInProgress[taskID] = + generation + + return true + } + + private func endActivityCreation( + taskID: String, + generation: UUID + ) { + stateLock.lock() + + if activityCreationInProgress[taskID] + == generation { + activityCreationInProgress + .removeValue( + forKey: taskID + ) + } + + stateLock.unlock() + } + + private func shouldHandleProgress( + taskID: String, + generation: UUID, + now: Date + ) -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + + guard + !suppressedTaskIDs.contains(taskID), + taskGenerations[taskID] + == generation + else { + return false + } + + if let previous = lastUpdateTime[taskID], + now.timeIntervalSince(previous) + < minimumUpdateInterval { + return false + } + + lastUpdateTime[taskID] = now + return true + } + + // MARK: - Gopeed event entry point + + @objc + func handleTaskEventPayload(_ payload: String) { + + guard #available(iOS 16.2, *) else { + return + } + + guard + let data = payload.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) + as? [String: Any], + let type = json["type"] as? String, + let taskID = json["taskId"] as? String + else { + print("LiveActivity: invalid task event payload") + return + } + + guard !isTaskSuppressed(taskID) else { + return + } + + let name = json["name"] as? String ?? "Download" + + switch type { + + case "task.start": + let generation = + startGeneration( + taskID: taskID + ) + + Task { + await refreshActivity( + taskID: taskID, + name: name, + generation: generation, + allowStart: true + ) + } + + case "task.progress": + guard + let generation = + currentGeneration( + taskID: taskID + ) + else { + return + } + + handleProgress( + taskID: taskID, + name: name, + generation: generation + ) + + case "task.pause": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await removeActivity( + taskID: taskID, + generation: generation, + reason: "paused" + ) + } + } + + case "task.done": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await finishActivity( + taskID: taskID, + generation: generation + ) + } + } + + case "task.error": + let error = + json["error"] as? String + ?? "Download failed" + + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await failActivity( + taskID: taskID, + generation: generation, + error: error + ) + } + } + + case "task.delete": + if let generation = + invalidateGeneration( + taskID: taskID + ) { + Task { + await removeActivity( + taskID: taskID, + generation: generation, + reason: "removed" + ) + } + } + + default: + break + } + } + + + // MARK: - Progress throttling + + @available(iOS 16.2, *) + private func handleProgress( + taskID: String, + name: String, + generation: UUID + ) { + let now = Date() + + guard shouldHandleProgress( + taskID: taskID, + generation: generation, + now: now + ) else { + return + } + + Task { + await refreshActivity( + taskID: taskID, + name: name, + generation: generation, + allowStart: true + ) + } + } + + + // MARK: - Async Gopeed invocation + + private func invokeGopeed( + method: String, + path: String, + query: String = "", + body: String = "" + ) async -> String? { + + await withCheckedContinuation { + continuation in + + GopeedInvokeAsyncNative( + method, + path, + query, + body + ) { success, payload in + + continuation.resume( + returning: + success + ? payload + : nil + ) + } + } + } + + + // MARK: - Gopeed runtime status + + private struct RuntimeStatus { + let status: String + let downloaded: Int64 + let total: Int64 + let speed: Int64 + } + + private func getRuntimeStatus( + taskID: String + ) async -> RuntimeStatus? { + + guard + let response = + await invokeGopeed( + method: "GET", + path: + "/api/v1/tasks/\(taskID)/status" + ) + else { + print( + "LiveActivity: status request failed for \(taskID)" + ) + return nil + } + + guard + let data = response.data(using: .utf8), + let root = + try? JSONSerialization.jsonObject( + with: data + ) as? [String: Any] + else { + print( + "LiveActivity: failed to decode status for \(taskID)" + ) + return nil + } + + let code = + (root["code"] as? NSNumber)?.intValue + ?? -1 + + guard code == 0 else { + let message = + root["msg"] as? String + ?? "Unknown Gopeed API error" + + print( + "LiveActivity: Gopeed status error:", + message + ) + + return nil + } + + guard + let body = + root["data"] as? [String: Any] + else { + return nil + } + + return RuntimeStatus( + status: + body["status"] as? String ?? "", + downloaded: + (body["downloaded"] as? NSNumber)? + .int64Value ?? 0, + total: + (body["total"] as? NSNumber)? + .int64Value ?? 0, + speed: + (body["speed"] as? NSNumber)? + .int64Value ?? 0 + ) + } + + // MARK: - Build Live Activity state + + @available(iOS 16.2, *) + private func makeState( + from runtime: RuntimeStatus + ) -> GopeedDownloadAttributes.ContentState { + + let now = Date() + + let total = + max(runtime.total, 0) + + let downloaded: Int64 + + if total > 0 { + downloaded = + min( + max(runtime.downloaded, 0), + total + ) + } else { + downloaded = + max(runtime.downloaded, 0) + } + + let progress: Double + + if total > 0 { + progress = + min( + max( + Double(downloaded) + / Double(total), + 0 + ), + 1 + ) + } else { + progress = 0 + } + + // If we don't have enough information for an ETA, + // use the real static progress bar. + guard + runtime.status == "running", + total > 0, + runtime.speed > 0, + downloaded < total, + progress > 0, + progress < 1 + else { + return GopeedDownloadAttributes + .ContentState( + progress: progress, + downloaded: downloaded, + total: total, + speed: runtime.speed, + status: runtime.status, + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + } + + let remainingBytes = + Double(total - downloaded) + + var remainingSeconds = + remainingBytes + / Double(runtime.speed) + + // Don't allow corrupt speed values to create + // ridiculous date ranges. + remainingSeconds = + min( + max(remainingSeconds, 1), + 86_400 + ) + + let remainingFraction = + max(1.0 - progress, 0.001) + + let estimatedTotalDuration = + remainingSeconds + / remainingFraction + + let elapsedEstimate = + estimatedTotalDuration + * progress + + let estimatedStart = + now.addingTimeInterval( + -elapsedEstimate + ) + + let estimatedEnd = + now.addingTimeInterval( + remainingSeconds + ) + + return GopeedDownloadAttributes + .ContentState( + progress: progress, + downloaded: downloaded, + total: total, + speed: runtime.speed, + status: runtime.status, + estimatedStart: estimatedStart, + estimatedEnd: estimatedEnd, + usesEstimatedProgress: true + ) + } + + + // MARK: - Find existing Activity + + @available(iOS 16.2, *) + private func findActivities( + taskID: String + ) -> [Activity] { + return Activity< + GopeedDownloadAttributes + > + .activities + .filter { + $0.attributes.taskId == taskID + } + } + + @available(iOS 16.2, *) + private func findActivities( + taskID: String, + generation: UUID + ) -> [Activity] { + let generationID = + generation.uuidString + + return findActivities( + taskID: taskID + ) + .filter { + $0.attributes.generation + == generationID + } + } + + @available(iOS 16.2, *) + private func collapseDuplicateActivities( + _ activities: [Activity], + content: ActivityContent + ) async { + guard let primary = activities.first else { + return + } + + await primary.update(content) + + if activities.count > 1 { + for duplicate in activities.dropFirst() { + await duplicate.end( + nil, + dismissalPolicy: .immediate + ) + } + + print( + "LiveActivity: removed", + activities.count - 1, + "duplicate activity/activities for", + primary.attributes.taskId + ) + } + } + + @available(iOS 16.2, *) + private func removeStaleActivities( + taskID: String, + keeping generation: UUID + ) async { + let generationID = + generation.uuidString + + let stale = + findActivities(taskID: taskID) + .filter { + $0.attributes.generation + != generationID + } + + for activity in stale { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + } + + + // MARK: - Start / update Activity + + @available(iOS 16.2, *) + private func refreshActivity( + taskID: String, + name: String, + generation: UUID, + allowStart: Bool + ) async { + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + guard + let runtime = + await getRuntimeStatus( + taskID: taskID + ) + else { + return + } + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + let state = + makeState(from: runtime) + + let content = + ActivityContent( + state: state, + staleDate: nil + ) + + let existing = + findActivities( + taskID: taskID, + generation: generation + ) + + if !existing.isEmpty { + await collapseDuplicateActivities( + existing, + content: content + ) + return + } + + guard + allowStart, + runtime.status == "running" + else { + return + } + + guard + ActivityAuthorizationInfo() + .areActivitiesEnabled + else { + print( + "LiveActivity: activities disabled" + ) + return + } + + let appIsActive = + await MainActor.run { + UIApplication.shared + .applicationState == .active + } + + guard + appIsActive, + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + guard beginActivityCreation( + taskID: taskID, + generation: generation + ) else { + return + } + + defer { + endActivityCreation( + taskID: taskID, + generation: generation + ) + } + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + await removeStaleActivities( + taskID: taskID, + keeping: generation + ) + + guard + !isTaskSuppressed(taskID), + isCurrentGeneration( + taskID: taskID, + generation: generation + ) + else { + return + } + + let recheck = + findActivities( + taskID: taskID, + generation: generation + ) + + if !recheck.isEmpty { + await collapseDuplicateActivities( + recheck, + content: content + ) + return + } + + let attributes = + GopeedDownloadAttributes( + taskId: taskID, + fileName: name, + generation: + generation.uuidString + ) + + do { + let activity = + try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + + if + isTaskSuppressed(taskID) + || !isCurrentGeneration( + taskID: taskID, + generation: generation + ) + { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + return + } + + print( + "LiveActivity: started", + activity.id, + taskID + ) + } catch { + print( + "LiveActivity: start failed:", + error + ) + } + } + + + // MARK: - Complete Activity + + @available(iOS 16.2, *) + private func finishActivity( + taskID: String, + generation: UUID + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + guard !activities.isEmpty else { + return + } + + let now = Date() + + for activity in activities { + let oldState = + activity.content.state + + let finalTotal = + max( + oldState.total, + oldState.downloaded + ) + + let finalState = + GopeedDownloadAttributes + .ContentState( + progress: 1.0, + downloaded: finalTotal, + total: finalTotal, + speed: 0, + status: "done", + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + + let finalContent = + ActivityContent( + state: finalState, + staleDate: nil + ) + + await activity.end( + finalContent, + dismissalPolicy: + .after( + Date() + .addingTimeInterval(15) + ) + ) + } + + print( + "LiveActivity: completed", + taskID + ) + } + + + // MARK: - Error Activity + + @available(iOS 16.2, *) + private func failActivity( + taskID: String, + generation: UUID, + error: String + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + guard !activities.isEmpty else { + return + } + + let now = Date() + + for activity in activities { + let old = + activity.content.state + + let failedState = + GopeedDownloadAttributes + .ContentState( + progress: old.progress, + downloaded: old.downloaded, + total: old.total, + speed: 0, + status: "error", + estimatedStart: now, + estimatedEnd: + now.addingTimeInterval(1), + usesEstimatedProgress: false + ) + + let content = + ActivityContent( + state: failedState, + staleDate: nil + ) + + await activity.end( + content, + dismissalPolicy: + .after( + Date() + .addingTimeInterval(30) + ) + ) + } + + print( + "LiveActivity: task failed:", + taskID, + error + ) + } + + + // MARK: - Delete / suppression cleanup + + @available(iOS 16.2, *) + private func removeActivity( + taskID: String, + generation: UUID, + reason: String + ) async { + let activities = + findActivities( + taskID: taskID, + generation: generation + ) + + for activity in activities { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + + if !activities.isEmpty { + print( + "LiveActivity:", + reason, + taskID, + "count:", + activities.count + ) + } + } + + @available(iOS 16.2, *) + private func removeAllActivities( + taskID: String, + reason: String + ) async { + let activities = + findActivities( + taskID: taskID + ) + + for activity in activities { + await activity.end( + nil, + dismissalPolicy: .immediate + ) + } + + if !activities.isEmpty { + print( + "LiveActivity:", + reason, + taskID, + "count:", + activities.count + ) + } + } + +} diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h index 6f9869e6..2a63353d 100644 --- a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.h @@ -13,6 +13,18 @@ FOUNDATION_EXPORT void GopeedSubscribeTaskEventsWithForwarder( int64_t mask, GopeedTaskEventForwarder * _Nullable listener); + +typedef void (^GopeedNativeInvokeCompletion)( + BOOL success, + NSString *payload); + +FOUNDATION_EXPORT void GopeedInvokeAsyncNative( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + GopeedNativeInvokeCompletion completion); + FOUNDATION_EXPORT void GopeedInvokeAsyncWithResult( NSString *method, NSString *path, diff --git a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m index e35bd5cf..d0330667 100644 --- a/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m +++ b/ui/flutter/ios/Runner/GopeedTaskEventForwarder.m @@ -2,6 +2,8 @@ #import +#import "Runner-Swift.h" + @interface GopeedTaskEventForwarder () @property(nonatomic, strong) FlutterMethodChannel *channel; @@ -47,6 +49,45 @@ @end + +@interface GopeedNativeInvokeResultForwarder + : NSObject + +@property(nonatomic, copy, nullable) + GopeedNativeInvokeCompletion completion; + +- (instancetype)initWithCompletion: + (GopeedNativeInvokeCompletion)completion; + +@end + +@implementation GopeedNativeInvokeResultForwarder + +- (instancetype)initWithCompletion: + (GopeedNativeInvokeCompletion)completion { + self = [super init]; + if (self) { + _completion = [completion copy]; + } + return self; +} + +- (void)onResult:(int64_t)requestID + success:(BOOL)success + payload:(NSString * _Nullable)payload { + GopeedNativeInvokeCompletion completion = + self.completion; + self.completion = nil; + + if (completion == nil) { + return; + } + + completion(success, payload ?: @""); +} + +@end + @implementation GopeedTaskEventForwarder - (instancetype)initWithChannel:(FlutterMethodChannel *)channel { @@ -59,9 +100,59 @@ - (void)onTaskEvent:(NSString * _Nullable)payload { NSString *arguments = payload ?: @""; + + NSData *data = + [arguments dataUsingEncoding:NSUTF8StringEncoding]; + + NSDictionary *json = nil; + + if (data) { + json = + [NSJSONSerialization JSONObjectWithData:data + options:0 + error:nil]; + } + + NSString *type = json[@"type"]; + NSString *taskID = json[@"taskId"]; + + BOOL continuedProcessingHandlesTask = NO; + + // iOS 26 system continued-processing Live Activity. + if (@available(iOS 26.0, *)) { + GopeedContinuedProcessingManager *manager = + [GopeedContinuedProcessingManager shared]; + + [manager handleTaskEventPayload:arguments]; + + if (taskID.length > 0) { + continuedProcessingHandlesTask = + [manager isHandlingTaskId:taskID]; + } + } + + // When BGCPT is active, let Apple's system Live Activity + // represent progress. Otherwise keep using our existing + // custom Gopeed ActivityKit implementation. + if (!continuedProcessingHandlesTask) { + [[GopeedLiveActivityManager shared] + handleTaskEventPayload:arguments]; + } + + // Flutter currently understands only done/error. + BOOL flutterEvent = + [type isEqualToString:@"task.done"] || + [type isEqualToString:@"task.error"]; + + if (!flutterEvent) { + return; + } + FlutterMethodChannel *channel = self.channel; + dispatch_async(dispatch_get_main_queue(), ^{ - [channel invokeMethod:@"taskEvent" arguments:arguments]; + [channel invokeMethod:@"taskEvent" + arguments:arguments]; }); } @@ -84,3 +175,26 @@ void GopeedInvokeAsyncWithResult( [[GopeedInvokeResultForwarder alloc] initWithResult:result]; LibgopeedInvokeAsync(method, path, query, body, requestID, forwarder); } + +void GopeedInvokeAsyncNative( + NSString *method, + NSString *path, + NSString *query, + NSString *body, + GopeedNativeInvokeCompletion completion) { + if (completion == nil) { + return; + } + + GopeedNativeInvokeResultForwarder *forwarder = + [[GopeedNativeInvokeResultForwarder alloc] + initWithCompletion:completion]; + + LibgopeedInvokeAsync( + method, + path, + query, + body, + 0, + forwarder); +} diff --git a/ui/flutter/ios/Runner/Info.plist b/ui/flutter/ios/Runner/Info.plist index 4c19e631..61892b05 100644 --- a/ui/flutter/ios/Runner/Info.plist +++ b/ui/flutter/ios/Runner/Info.plist @@ -115,6 +115,7 @@ UIBackgroundModes location + processing UIApplicationSceneManifest @@ -156,5 +157,14 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSSupportsLiveActivities + + + NSSupportsLiveActivitiesFrequentUpdates + + BGTaskSchedulerPermittedIdentifiers + + $(PRODUCT_BUNDLE_IDENTIFIER).continuedDownload.* + diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index d824f6d8..dae6a103 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -145,6 +145,7 @@ class ExtraConfig { bool notifyWhenNewVersion; bool desktopNotification; bool backgroundLocationKeepAlive; + bool backgroundContinuedProcessing; WindowStateConfig windowState; Map bookmarks; List createHistory; @@ -166,6 +167,7 @@ class ExtraConfig { this.notifyWhenNewVersion = true, this.desktopNotification = true, this.backgroundLocationKeepAlive = false, + this.backgroundContinuedProcessing = false, WindowStateConfig? windowState, this.bookmarks = const {}, this.createHistory = const [], diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index 2457ce2b..2ee3c2d8 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -135,6 +135,10 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true, desktopNotification: json['desktopNotification'] as bool? ?? true, backgroundLocationKeepAlive: json['backgroundLocationKeepAlive'] as bool? ?? false, + backgroundContinuedProcessing: + json['backgroundContinuedProcessing'] + as bool? ?? + false, windowState: json['windowState'] == null ? null : WindowStateConfig.fromJson(json['windowState'] as Map?), @@ -162,6 +166,8 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => { ), ); unawaited(LocationKeepAliveCoordinator.instance.reconcile(enabled: config.extra.backgroundLocationKeepAlive)); + unawaited( + ContinuedProcessing.setEnabled( + config.extra.backgroundContinuedProcessing, + ), + ); } + Future _init() async { await AppInitializer.ensureStorageInitialized(); try { @@ -180,6 +187,13 @@ class AppRuntimeController extends AsyncNotifier { startupError: startupError, ); if (Util.isIOS()) { + // Make sure the native Continued Processing manager has the + // persisted setting before app initialization completes. This + // avoids the first download racing ahead of setEnabled(true). + await ContinuedProcessing.setEnabled( + config.extra.backgroundContinuedProcessing, + ); + LocationKeepAliveCoordinator.instance.start( () => state.value?.downloaderConfig.extra.backgroundLocationKeepAlive ?? config.extra.backgroundLocationKeepAlive, diff --git a/ui/flutter/lib/app/application/continued_processing.dart b/ui/flutter/lib/app/application/continued_processing.dart new file mode 100644 index 00000000..08bec054 --- /dev/null +++ b/ui/flutter/lib/app/application/continued_processing.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class ContinuedProcessing { + const ContinuedProcessing._(); + + static const _channel = + MethodChannel('gopeed/continued_processing'); + + static Future isSupported() async { + if (!Platform.isIOS) return false; + + try { + return await _channel.invokeMethod( + 'isSupported', + ) ?? + false; + } catch (_) { + return false; + } + } + + static Future setEnabled( + bool enabled, + ) async { + if (!Platform.isIOS) return false; + + try { + return await _channel.invokeMethod( + 'setEnabled', + {'enabled': enabled}, + ) ?? + false; + } catch (_) { + return false; + } + } +} diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index f772b883..c78a18a1 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -13,6 +13,7 @@ import '../../../../app/application/app_appearance_controller.dart'; import '../../../../app/application/app_platform_controller.dart'; import '../../../../app/application/app_runtime_controller.dart'; import '../../../../app/application/location_keep_alive.dart'; +import '../../../../app/application/continued_processing.dart'; import '../../../../core/common/start_config.dart'; import '../../../../core/network/gopeed/gopeed_transport.dart'; import '../../../../core/utils/breakpoints.dart'; @@ -381,6 +382,19 @@ class _SettingsPageState extends ConsumerState { onChanged: (value) => unawaited(_setBackgroundLocationKeepAlive(value)), ), ), + if (Util.isIOS()) + SettingsItem( + title: context.l10n.backgroundContinuedProcessing, + subtitle: + context.l10n.backgroundContinuedProcessingDescription, + child: shad.Switch( + value: + config.extra.backgroundContinuedProcessing, + onChanged: (value) => unawaited( + _setBackgroundContinuedProcessing(value), + ), + ), + ), ], ), ), @@ -1281,6 +1295,43 @@ class _SettingsPageState extends ConsumerState { _mutateConfig((config) => config.extra.backgroundLocationKeepAlive = enabled); await LocationKeepAliveCoordinator.instance.reconcile(enabled: enabled); } + Future + _setBackgroundContinuedProcessing( + bool enabled, + ) async { + if (enabled && + !await ContinuedProcessing.isSupported()) { + if (mounted) { + _toast( + context.l10n + .backgroundContinuedProcessingUnsupported, + ); + } + return; + } + + final applied = + await ContinuedProcessing.setEnabled( + enabled, + ); + + if (enabled && !applied) { + if (mounted) { + _toast( + context.l10n + .backgroundContinuedProcessingUnsupported, + ); + } + return; + } + + _mutateConfig( + (config) => + config.extra + .backgroundContinuedProcessing = + enabled, + ); + } List _lines(String text) { return text.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 69bcfdc0..9af2806f 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -249,6 +249,7 @@ "ed2kNodesDatHint": "nodes.dat path or URL, one per line", "desktopNotification": "Desktop Notifications", "backgroundLocationKeepAlive": "Background Downloads", + "backgroundContinuedProcessing": "Continued Processing (iOS 26+)", "notificationTaskDone": "Task completed", "notificationTaskError": "Task error", "appTitle": "Gopeed Download Manager", @@ -291,6 +292,8 @@ "browserExtensionDescription": "Send browser downloads directly to Gopeed after installation", "desktopNotificationDescription": "Show a system notification when a task completes or fails", "backgroundLocationKeepAliveDescription": "Use location access to keep downloads active in the background", + "backgroundContinuedProcessingDescription": "Use iOS continued processing and its system Live Activity to keep downloads running without location access", + "backgroundContinuedProcessingUnsupported": "Continued Processing requires iOS 26 or later", "currentVersion": "Current version {version}", "contributors": "Contributors", "viewContributors": "View contributors",