From ec622aba5575f7d58174fc41d7df83d55133f7ef Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Thu, 2 Oct 2025 20:12:02 +0200 Subject: [PATCH] wip: bg downloader module --- modules/background-downloader/README.md | 235 +++++++++++++++++ modules/background-downloader/example.ts | 98 +++++++ .../expo-module.config.json | 8 + modules/background-downloader/index.ts | 84 ++++++ .../ios/BackgroundDownloader.podspec | 21 ++ .../ios/BackgroundDownloaderAppDelegate.swift | 15 ++ .../ios/BackgroundDownloaderModule.swift | 247 ++++++++++++++++++ .../src/BackgroundDownloader.types.ts | 35 +++ .../src/BackgroundDownloaderModule.ts | 7 + modules/index.ts | 17 +- 10 files changed, 765 insertions(+), 2 deletions(-) create mode 100644 modules/background-downloader/README.md create mode 100644 modules/background-downloader/example.ts create mode 100644 modules/background-downloader/expo-module.config.json create mode 100644 modules/background-downloader/index.ts create mode 100644 modules/background-downloader/ios/BackgroundDownloader.podspec create mode 100644 modules/background-downloader/ios/BackgroundDownloaderAppDelegate.swift create mode 100644 modules/background-downloader/ios/BackgroundDownloaderModule.swift create mode 100644 modules/background-downloader/src/BackgroundDownloader.types.ts create mode 100644 modules/background-downloader/src/BackgroundDownloaderModule.ts diff --git a/modules/background-downloader/README.md b/modules/background-downloader/README.md new file mode 100644 index 00000000..c3b10ff2 --- /dev/null +++ b/modules/background-downloader/README.md @@ -0,0 +1,235 @@ +# Background Downloader Module + +A native iOS module for downloading large files in the background using `NSURLSession` with background configuration. + +## Features + +- **Background Downloads**: Downloads continue even when the app is backgrounded or suspended +- **Progress Tracking**: Real-time progress updates via events +- **Multiple Downloads**: Support for concurrent downloads +- **Cancellation**: Cancel individual or all downloads +- **Custom Destination**: Optionally specify custom file paths +- **Error Handling**: Comprehensive error reporting + +## Usage + +### Basic Example + +```typescript +import { BackgroundDownloader } from '@/modules'; + +// Start a download +const taskId = await BackgroundDownloader.startDownload( + 'https://example.com/largefile.mp4' +); + +// Listen for progress updates +const progressSub = BackgroundDownloader.addProgressListener((event) => { + console.log(`Progress: ${Math.floor(event.progress * 100)}%`); + console.log(`Downloaded: ${event.bytesWritten} / ${event.totalBytes}`); +}); + +// Listen for completion +const completeSub = BackgroundDownloader.addCompleteListener((event) => { + console.log('Download complete!'); + console.log('File saved to:', event.filePath); + console.log('Task ID:', event.taskId); +}); + +// Listen for errors +const errorSub = BackgroundDownloader.addErrorListener((event) => { + console.error('Download failed:', event.error); +}); + +// Cancel a download +BackgroundDownloader.cancelDownload(taskId); + +// Get all active downloads +const activeDownloads = await BackgroundDownloader.getActiveDownloads(); + +// Cleanup listeners when done +progressSub.remove(); +completeSub.remove(); +errorSub.remove(); +``` + +### Custom Destination Path + +```typescript +import { BackgroundDownloader } from '@/modules'; +import * as FileSystem from 'expo-file-system'; + +const destinationPath = `${FileSystem.documentDirectory}myfile.mp4`; +const taskId = await BackgroundDownloader.startDownload( + 'https://example.com/video.mp4', + destinationPath +); +``` + +### Managing Multiple Downloads + +```typescript +import { BackgroundDownloader } from '@/modules'; + +const downloads = new Map(); + +async function startMultipleDownloads(urls: string[]) { + for (const url of urls) { + const taskId = await BackgroundDownloader.startDownload(url); + downloads.set(taskId, { url, progress: 0 }); + } +} + +// Track progress for each download +const progressSub = BackgroundDownloader.addProgressListener((event) => { + const download = downloads.get(event.taskId); + if (download) { + download.progress = event.progress; + } +}); + +// Cancel all downloads +BackgroundDownloader.cancelAllDownloads(); +``` + +## API Reference + +### Methods + +#### `startDownload(url: string, destinationPath?: string): Promise` + +Starts a new background download. + +- **Parameters:** + - `url`: The URL of the file to download + - `destinationPath`: (Optional) Custom file path for the downloaded file +- **Returns:** Promise that resolves to the task ID (number) + +#### `cancelDownload(taskId: number): void` + +Cancels a specific download by task ID. + +- **Parameters:** + - `taskId`: The task ID returned by `startDownload` + +#### `cancelAllDownloads(): void` + +Cancels all active downloads. + +#### `getActiveDownloads(): Promise` + +Gets information about all active downloads. + +- **Returns:** Promise that resolves to an array of active downloads + +### Event Listeners + +#### `addProgressListener(listener: (event: DownloadProgressEvent) => void): Subscription` + +Listens for download progress updates. + +- **Event payload:** + - `taskId`: number + - `bytesWritten`: number + - `totalBytes`: number + - `progress`: number (0.0 to 1.0) + +#### `addCompleteListener(listener: (event: DownloadCompleteEvent) => void): Subscription` + +Listens for download completion. + +- **Event payload:** + - `taskId`: number + - `filePath`: string + - `url`: string + +#### `addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription` + +Listens for download errors. + +- **Event payload:** + - `taskId`: number + - `error`: string + +#### `addStartedListener(listener: (event: DownloadStartedEvent) => void): Subscription` + +Listens for download start confirmation. + +- **Event payload:** + - `taskId`: number + - `url`: string + +## Types + +```typescript +interface DownloadProgressEvent { + taskId: number; + bytesWritten: number; + totalBytes: number; + progress: number; +} + +interface DownloadCompleteEvent { + taskId: number; + filePath: string; + url: string; +} + +interface DownloadErrorEvent { + taskId: number; + error: string; +} + +interface DownloadStartedEvent { + taskId: number; + url: string; +} + +interface ActiveDownload { + taskId: number; + url: string; + state: 'running' | 'suspended' | 'canceling' | 'completed' | 'unknown'; +} +``` + +## Implementation Details + +### iOS Background Downloads + +- Uses `NSURLSession` with background configuration +- Session identifier: `com.fredrikburmester.streamyfin.backgrounddownloader` +- Downloads continue when app is backgrounded or suspended +- System may terminate downloads if app is force-quit + +### Background Modes + +The app's `Info.plist` already includes the required background mode: + +- `UIBackgroundModes`: `["audio", "fetch"]` + +### File Storage + +By default, downloaded files are saved to the app's Documents directory. You can specify a custom path using the `destinationPath` parameter. + +## Building + +After adding this module, rebuild the iOS app: + +```bash +npx expo prebuild -p ios +npx expo run:ios +``` + +Or install pods manually: + +```bash +cd ios +pod install +cd .. +``` + +## Notes + +- Background downloads may be cancelled if the user force-quits the app +- The OS manages download priority and may pause downloads to save battery +- Downloads over cellular can be disabled in the module configuration if needed diff --git a/modules/background-downloader/example.ts b/modules/background-downloader/example.ts new file mode 100644 index 00000000..51dd15af --- /dev/null +++ b/modules/background-downloader/example.ts @@ -0,0 +1,98 @@ +import type { + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, +} from "@/modules"; +import { BackgroundDownloader } from "@/modules"; + +export class DownloadManager { + private progressSubscription: any; + private completeSubscription: any; + private errorSubscription: any; + private activeDownloads = new Map< + number, + { url: string; progress: number } + >(); + + constructor() { + this.setupListeners(); + } + + private setupListeners() { + this.progressSubscription = BackgroundDownloader.addProgressListener( + (event: DownloadProgressEvent) => { + const download = this.activeDownloads.get(event.taskId); + if (download) { + download.progress = event.progress; + console.log( + `Download ${event.taskId}: ${Math.floor(event.progress * 100)}%`, + ); + } + }, + ); + + this.completeSubscription = BackgroundDownloader.addCompleteListener( + (event: DownloadCompleteEvent) => { + console.log("Download complete:", event.filePath); + this.activeDownloads.delete(event.taskId); + }, + ); + + this.errorSubscription = BackgroundDownloader.addErrorListener( + (event: DownloadErrorEvent) => { + console.error("Download error:", event.error); + this.activeDownloads.delete(event.taskId); + }, + ); + } + + async startDownload(url: string, destinationPath?: string): Promise { + const taskId = await BackgroundDownloader.startDownload( + url, + destinationPath, + ); + this.activeDownloads.set(taskId, { url, progress: 0 }); + return taskId; + } + + cancelDownload(taskId: number): void { + BackgroundDownloader.cancelDownload(taskId); + this.activeDownloads.delete(taskId); + } + + cancelAllDownloads(): void { + BackgroundDownloader.cancelAllDownloads(); + this.activeDownloads.clear(); + } + + async getActiveDownloads() { + return await BackgroundDownloader.getActiveDownloads(); + } + + cleanup(): void { + this.progressSubscription?.remove(); + this.completeSubscription?.remove(); + this.errorSubscription?.remove(); + } +} + +const downloadManager = new DownloadManager(); + +export async function downloadFile( + url: string, + destinationPath?: string, +): Promise { + return await downloadManager.startDownload(url, destinationPath); +} + +export function cancelDownload(taskId: number): void { + downloadManager.cancelDownload(taskId); +} + +export function cancelAllDownloads(): void { + downloadManager.cancelAllDownloads(); +} + +export async function getActiveDownloads() { + return await downloadManager.getActiveDownloads(); +} diff --git a/modules/background-downloader/expo-module.config.json b/modules/background-downloader/expo-module.config.json new file mode 100644 index 00000000..d88ee6f6 --- /dev/null +++ b/modules/background-downloader/expo-module.config.json @@ -0,0 +1,8 @@ +{ + "name": "background-downloader", + "version": "1.0.0", + "platforms": ["ios"], + "ios": { + "appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"] + } +} diff --git a/modules/background-downloader/index.ts b/modules/background-downloader/index.ts new file mode 100644 index 00000000..d00f5999 --- /dev/null +++ b/modules/background-downloader/index.ts @@ -0,0 +1,84 @@ +import { EventEmitter, type Subscription } from "expo-modules-core"; +import type { + ActiveDownload, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, +} from "./src/BackgroundDownloader.types"; +import BackgroundDownloaderModule from "./src/BackgroundDownloaderModule"; + +const emitter = new EventEmitter(BackgroundDownloaderModule); + +export interface BackgroundDownloader { + startDownload(url: string, destinationPath?: string): Promise; + cancelDownload(taskId: number): void; + cancelAllDownloads(): void; + getActiveDownloads(): Promise; + + addProgressListener( + listener: (event: DownloadProgressEvent) => void, + ): Subscription; + + addCompleteListener( + listener: (event: DownloadCompleteEvent) => void, + ): Subscription; + + addErrorListener(listener: (event: DownloadErrorEvent) => void): Subscription; + + addStartedListener( + listener: (event: DownloadStartedEvent) => void, + ): Subscription; +} + +const BackgroundDownloader: BackgroundDownloader = { + async startDownload(url: string, destinationPath?: string): Promise { + return await BackgroundDownloaderModule.startDownload(url, destinationPath); + }, + + cancelDownload(taskId: number): void { + BackgroundDownloaderModule.cancelDownload(taskId); + }, + + cancelAllDownloads(): void { + BackgroundDownloaderModule.cancelAllDownloads(); + }, + + async getActiveDownloads(): Promise { + return await BackgroundDownloaderModule.getActiveDownloads(); + }, + + addProgressListener( + listener: (event: DownloadProgressEvent) => void, + ): Subscription { + return emitter.addListener("onDownloadProgress", listener); + }, + + addCompleteListener( + listener: (event: DownloadCompleteEvent) => void, + ): Subscription { + return emitter.addListener("onDownloadComplete", listener); + }, + + addErrorListener( + listener: (event: DownloadErrorEvent) => void, + ): Subscription { + return emitter.addListener("onDownloadError", listener); + }, + + addStartedListener( + listener: (event: DownloadStartedEvent) => void, + ): Subscription { + return emitter.addListener("onDownloadStarted", listener); + }, +}; + +export default BackgroundDownloader; + +export type { + DownloadProgressEvent, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadStartedEvent, + ActiveDownload, +}; diff --git a/modules/background-downloader/ios/BackgroundDownloader.podspec b/modules/background-downloader/ios/BackgroundDownloader.podspec new file mode 100644 index 00000000..b1d778a1 --- /dev/null +++ b/modules/background-downloader/ios/BackgroundDownloader.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'BackgroundDownloader' + s.version = '1.0.0' + s.summary = 'Background file downloader for iOS' + s.description = 'Native iOS module for downloading large files in the background using NSURLSession' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { :ios => '15.6', :tvos => '15.0' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end + diff --git a/modules/background-downloader/ios/BackgroundDownloaderAppDelegate.swift b/modules/background-downloader/ios/BackgroundDownloaderAppDelegate.swift new file mode 100644 index 00000000..d34f97ff --- /dev/null +++ b/modules/background-downloader/ios/BackgroundDownloaderAppDelegate.swift @@ -0,0 +1,15 @@ +import ExpoModulesCore +import UIKit + +public class BackgroundDownloaderAppDelegate: ExpoAppDelegateSubscriber { + public func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + if identifier == "com.fredrikburmester.streamyfin.backgrounddownloader" { + BackgroundDownloaderModule.setBackgroundCompletionHandler(completionHandler) + } + } +} + diff --git a/modules/background-downloader/ios/BackgroundDownloaderModule.swift b/modules/background-downloader/ios/BackgroundDownloaderModule.swift new file mode 100644 index 00000000..89b10182 --- /dev/null +++ b/modules/background-downloader/ios/BackgroundDownloaderModule.swift @@ -0,0 +1,247 @@ +import ExpoModulesCore +import Foundation + +enum DownloadError: Error { + case invalidURL + case fileOperationFailed + case downloadFailed +} + +public class BackgroundDownloaderModule: Module, URLSessionDownloadDelegate { + private var session: URLSession? + private static var backgroundCompletionHandler: (() -> Void)? + private var downloadTasks: [Int: DownloadTaskInfo] = [:] + + struct DownloadTaskInfo { + let url: String + let destinationPath: String? + } + + public func definition() -> ModuleDefinition { + Name("BackgroundDownloader") + + Events( + "onDownloadProgress", + "onDownloadComplete", + "onDownloadError", + "onDownloadStarted" + ) + + OnCreate { + self.initializeSession() + } + + AsyncFunction("startDownload") { (urlString: String, destinationPath: String?) -> Int in + guard let url = URL(string: urlString) else { + throw DownloadError.invalidURL + } + + if self.session == nil { + self.initializeSession() + } + + guard let session = self.session else { + throw DownloadError.downloadFailed + } + + let task = session.downloadTask(with: url) + let taskId = task.taskIdentifier + + self.downloadTasks[taskId] = DownloadTaskInfo( + url: urlString, + destinationPath: destinationPath + ) + + task.resume() + + self.sendEvent("onDownloadStarted", [ + "taskId": taskId, + "url": urlString + ]) + + return taskId + } + + Function("cancelDownload") { (taskId: Int) in + self.session?.getAllTasks { tasks in + for task in tasks where task.taskIdentifier == taskId { + task.cancel() + self.downloadTasks.removeValue(forKey: taskId) + } + } + } + + Function("cancelAllDownloads") { + self.session?.getAllTasks { tasks in + for task in tasks { + task.cancel() + } + self.downloadTasks.removeAll() + } + } + + AsyncFunction("getActiveDownloads") { () -> [[String: Any]] in + return try await withCheckedThrowingContinuation { continuation in + self.session?.getAllTasks { tasks in + let activeDownloads = tasks.compactMap { task -> [String: Any]? in + guard let downloadTask = task as? URLSessionDownloadTask, + let info = self.downloadTasks[task.taskIdentifier] else { + return nil + } + + return [ + "taskId": task.taskIdentifier, + "url": info.url, + "state": self.taskStateString(task.state) + ] + } + continuation.resume(returning: activeDownloads) + } + } + } + } + + private func initializeSession() { + let config = URLSessionConfiguration.background( + withIdentifier: "com.fredrikburmester.streamyfin.backgrounddownloader" + ) + config.allowsCellularAccess = true + config.sessionSendsLaunchEvents = true + config.isDiscretionary = false + + self.session = URLSession( + configuration: config, + delegate: self, + delegateQueue: nil + ) + } + + private func taskStateString(_ state: URLSessionTask.State) -> String { + switch state { + case .running: + return "running" + case .suspended: + return "suspended" + case .canceling: + return "canceling" + case .completed: + return "completed" + @unknown default: + return "unknown" + } + } + + public func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + let progress = totalBytesExpectedToWrite > 0 + ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) + : 0.0 + + self.sendEvent("onDownloadProgress", [ + "taskId": downloadTask.taskIdentifier, + "bytesWritten": totalBytesWritten, + "totalBytes": totalBytesExpectedToWrite, + "progress": progress + ]) + } + + public func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let taskId = downloadTask.taskIdentifier + guard let taskInfo = downloadTasks[taskId] else { + self.sendEvent("onDownloadError", [ + "taskId": taskId, + "error": "Download task info not found" + ]) + return + } + + let fileManager = FileManager.default + + do { + let destinationURL: URL + + if let customPath = taskInfo.destinationPath { + destinationURL = URL(fileURLWithPath: customPath) + } else { + let documentsDir = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! + let filename = downloadTask.response?.suggestedFilename + ?? downloadTask.originalRequest?.url?.lastPathComponent + ?? "download_\(taskId)" + destinationURL = documentsDir.appendingPathComponent(filename) + } + + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + + let destinationDirectory = destinationURL.deletingLastPathComponent() + if !fileManager.fileExists(atPath: destinationDirectory.path) { + try fileManager.createDirectory( + at: destinationDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + } + + try fileManager.moveItem(at: location, to: destinationURL) + + self.sendEvent("onDownloadComplete", [ + "taskId": taskId, + "filePath": destinationURL.path, + "url": taskInfo.url + ]) + + downloadTasks.removeValue(forKey: taskId) + + } catch { + self.sendEvent("onDownloadError", [ + "taskId": taskId, + "error": "File operation failed: \(error.localizedDescription)" + ]) + } + } + + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + if let error = error { + let taskId = task.taskIdentifier + + let isCancelled = (error as NSError).code == NSURLErrorCancelled + + if !isCancelled { + self.sendEvent("onDownloadError", [ + "taskId": taskId, + "error": error.localizedDescription + ]) + } + + downloadTasks.removeValue(forKey: taskId) + } + } + + public func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + DispatchQueue.main.async { + if let completion = BackgroundDownloaderModule.backgroundCompletionHandler { + completion() + BackgroundDownloaderModule.backgroundCompletionHandler = nil + } + } + } + + static func setBackgroundCompletionHandler(_ handler: @escaping () -> Void) { + BackgroundDownloaderModule.backgroundCompletionHandler = handler + } +} + diff --git a/modules/background-downloader/src/BackgroundDownloader.types.ts b/modules/background-downloader/src/BackgroundDownloader.types.ts new file mode 100644 index 00000000..78f61c57 --- /dev/null +++ b/modules/background-downloader/src/BackgroundDownloader.types.ts @@ -0,0 +1,35 @@ +export interface DownloadProgressEvent { + taskId: number; + bytesWritten: number; + totalBytes: number; + progress: number; +} + +export interface DownloadCompleteEvent { + taskId: number; + filePath: string; + url: string; +} + +export interface DownloadErrorEvent { + taskId: number; + error: string; +} + +export interface DownloadStartedEvent { + taskId: number; + url: string; +} + +export interface ActiveDownload { + taskId: number; + url: string; + state: "running" | "suspended" | "canceling" | "completed" | "unknown"; +} + +export interface BackgroundDownloaderModuleType { + startDownload(url: string, destinationPath?: string): Promise; + cancelDownload(taskId: number): void; + cancelAllDownloads(): void; + getActiveDownloads(): Promise; +} diff --git a/modules/background-downloader/src/BackgroundDownloaderModule.ts b/modules/background-downloader/src/BackgroundDownloaderModule.ts new file mode 100644 index 00000000..d2df92b8 --- /dev/null +++ b/modules/background-downloader/src/BackgroundDownloaderModule.ts @@ -0,0 +1,7 @@ +import { requireNativeModule } from "expo-modules-core"; +import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types"; + +const BackgroundDownloaderModule: BackgroundDownloaderModuleType = + requireNativeModule("BackgroundDownloader"); + +export default BackgroundDownloaderModule; diff --git a/modules/index.ts b/modules/index.ts index 63eb373e..2863141f 100644 --- a/modules/index.ts +++ b/modules/index.ts @@ -1,3 +1,11 @@ +import type { + ActiveDownload, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, +} from "./background-downloader"; +import BackgroundDownloader from "./background-downloader"; import { ChapterInfo, PlaybackStatePayload, @@ -12,8 +20,8 @@ import { } from "./VlcPlayer.types"; import VlcPlayerView from "./VlcPlayerView"; -export { - VlcPlayerView, +export { VlcPlayerView, BackgroundDownloader }; +export type { VlcPlayerViewProps, VlcPlayerViewRef, PlaybackStatePayload, @@ -24,4 +32,9 @@ export { VlcPlayerSource, TrackInfo, ChapterInfo, + DownloadProgressEvent, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadStartedEvent, + ActiveDownload, };