wip: bg downloader module

This commit is contained in:
Fredrik Burmester
2025-10-02 20:12:02 +02:00
parent a39461e09a
commit ec622aba55
10 changed files with 765 additions and 2 deletions

View File

@@ -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<number>`
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<ActiveDownload[]>`
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

View File

@@ -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<number> {
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<number> {
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();
}

View File

@@ -0,0 +1,8 @@
{
"name": "background-downloader",
"version": "1.0.0",
"platforms": ["ios"],
"ios": {
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
}
}

View File

@@ -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<number>;
cancelDownload(taskId: number): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
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<number> {
return await BackgroundDownloaderModule.startDownload(url, destinationPath);
},
cancelDownload(taskId: number): void {
BackgroundDownloaderModule.cancelDownload(taskId);
},
cancelAllDownloads(): void {
BackgroundDownloaderModule.cancelAllDownloads();
},
async getActiveDownloads(): Promise<ActiveDownload[]> {
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,
};

View File

@@ -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

View File

@@ -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)
}
}
}

View File

@@ -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
}
}

View File

@@ -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<number>;
cancelDownload(taskId: number): void;
cancelAllDownloads(): void;
getActiveDownloads(): Promise<ActiveDownload[]>;
}

View File

@@ -0,0 +1,7 @@
import { requireNativeModule } from "expo-modules-core";
import type { BackgroundDownloaderModuleType } from "./BackgroundDownloader.types";
const BackgroundDownloaderModule: BackgroundDownloaderModuleType =
requireNativeModule("BackgroundDownloader");
export default BackgroundDownloaderModule;

View File

@@ -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,
};