wip: android support

This commit is contained in:
Fredrik Burmester
2025-10-03 11:15:33 +02:00
parent 4517fe354b
commit e905737d5b
24 changed files with 491 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
# Background Downloader Module
A native iOS module for downloading large files in the background using `NSURLSession` with background configuration.
A native iOS and Android module for downloading large files in the background using `NSURLSession` (iOS) and `DownloadManager` (Android).
## Features
@@ -10,6 +10,7 @@ A native iOS module for downloading large files in the background using `NSURLSe
- **Cancellation**: Cancel individual or all downloads
- **Custom Destination**: Optionally specify custom file paths
- **Error Handling**: Comprehensive error reporting
- **Cross-Platform**: Works on both iOS and Android
## Usage
@@ -201,35 +202,57 @@ interface ActiveDownload {
- Downloads continue when app is backgrounded or suspended
- System may terminate downloads if app is force-quit
### Android Background Downloads
- Uses Android's `DownloadManager` API
- Downloads are managed by the system and continue in the background
- Shows download notification in the notification tray
- Downloads continue even if the app is closed
- Requires `INTERNET` permission (automatically added by Expo)
### Background Modes
The app's `Info.plist` already includes the required background mode:
The app's `Info.plist` already includes the required background mode for iOS:
- `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.
**iOS:** By default, downloaded files are saved to the app's Documents directory.
**Android:** By default, files are saved to the app's external files directory (accessible via `FileSystem.documentDirectory` in Expo).
You can specify a custom path using the `destinationPath` parameter on both platforms.
## Building
After adding this module, rebuild the iOS app:
After adding this module, rebuild the app:
```bash
# iOS
npx expo prebuild -p ios
npx expo run:ios
# Android
npx expo prebuild -p android
npx expo run:android
```
Or install pods manually:
Or install manually:
```bash
# iOS
cd ios
pod install
cd ..
# Android - prebuild handles everything
npx expo prebuild -p android
```
## Notes
- Background downloads may be cancelled if the user force-quits the app
- Background downloads may be cancelled if the user force-quits the app (iOS)
- The OS manages download priority and may pause downloads to save battery
- Downloads over cellular can be disabled in the module configuration if needed
- Android shows a system notification for ongoing downloads
- Downloads over cellular are allowed by default on both platforms

View File

@@ -0,0 +1,46 @@
plugins {
id 'com.android.library'
id 'kotlin-android'
}
group = 'expo.modules.backgrounddownloader'
version = '1.0.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
def kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useDefaultAndroidSdkVersions()
useCoreDependencies()
useExpoPublishing()
android {
namespace "expo.modules.backgrounddownloader"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion"
}
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
freeCompilerArgs += ["-Xshow-kotlin-compiler-errors"]
jvmTarget = "17"
}
}

View File

@@ -0,0 +1,10 @@
/**
* Automatically generated file. DO NOT MODIFY
*/
package expo.modules.backgrounddownloader;
public final class BuildConfig {
public static final boolean DEBUG = Boolean.parseBoolean("true");
public static final String LIBRARY_PACKAGE_NAME = "expo.modules.backgrounddownloader";
public static final String BUILD_TYPE = "debug";
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="expo.modules.backgrounddownloader" >
<uses-sdk android:minSdkVersion="24" />
</manifest>

View File

@@ -0,0 +1,18 @@
{
"version": 3,
"artifactType": {
"type": "AAPT_FRIENDLY_MERGED_MANIFESTS",
"kind": "Directory"
},
"applicationId": "expo.modules.backgrounddownloader",
"variantName": "debug",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"outputFile": "AndroidManifest.xml"
}
],
"elementType": "File"
}

View File

@@ -0,0 +1,6 @@
aarFormatVersion=1.0
aarMetadataVersion=1.0
minCompileSdk=1
minCompileSdkExtension=0
minAndroidGradlePluginVersion=1.0.0
coreLibraryDesugaringEnabled=false

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="main" generated-set="main$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="debug" generated-set="debug$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/debug/res"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated$Generated" generated="true" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/build/generated/res/resValues/debug"/></dataSet><dataSet aapt-namespace="http://schemas.android.com/apk/res-auto" config="generated" generated-set="generated$Generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/build/generated/res/resValues/debug"/></dataSet><mergedItems/></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/assets"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/debug/assets"/></dataSet><dataSet config="generated" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/build/intermediates/shader_assets/debug/compileDebugShaders/out"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/jniLibs"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/debug/jniLibs"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<merger version="3"><dataSet config="main" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/shaders"/></dataSet><dataSet config="debug" ignore_pattern="!.svn:!.git:!.ds_store:!*.scc:.*:&lt;dir>_*:!CVS:!thumbs.db:!picasa.ini:!*~"><source path="/Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/debug/shaders"/></dataSet></merger>

View File

@@ -0,0 +1,2 @@
R_DEF: Internal format may change without notice
local

View File

@@ -0,0 +1,7 @@
1<?xml version="1.0" encoding="utf-8"?>
2<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3 package="expo.modules.backgrounddownloader" >
4
5 <uses-sdk android:minSdkVersion="24" />
6
7</manifest>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="expo.modules.backgrounddownloader" >
<uses-sdk android:minSdkVersion="24" />
</manifest>

View File

@@ -0,0 +1,14 @@
-- Merging decision tree log ---
manifest
ADDED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml:1:1-2:12
INJECTED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml:1:1-2:12
package
INJECTED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml
uses-sdk
INJECTED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml reason: use-sdk injection requested
INJECTED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml
INJECTED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml
android:targetSdkVersion
INJECTED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml
android:minSdkVersion
INJECTED from /Users/fredrikburmester/Documents/GitHub/streamyfin/modules/background-downloader/android/src/main/AndroidManifest.xml

View File

@@ -0,0 +1,3 @@
<manifest>
</manifest>

View File

@@ -0,0 +1,324 @@
package expo.modules.backgrounddownloader
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.content.ContextCompat
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File
class BackgroundDownloaderModule : Module() {
private val context
get() = requireNotNull(appContext.reactContext)
private val downloadManager: DownloadManager by lazy {
context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
}
private val downloadTasks = mutableMapOf<Long, DownloadTaskInfo>()
private val progressHandler = Handler(Looper.getMainLooper())
private val progressRunnables = mutableMapOf<Long, Runnable>()
private val downloadCompleteReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val downloadId = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
if (downloadId != -1L && downloadTasks.containsKey(downloadId)) {
handleDownloadComplete(downloadId)
}
}
}
override fun definition() = ModuleDefinition {
Name("BackgroundDownloader")
Events(
"onDownloadProgress",
"onDownloadComplete",
"onDownloadError",
"onDownloadStarted"
)
OnCreate {
registerDownloadReceiver()
}
OnDestroy {
unregisterDownloadReceiver()
progressRunnables.values.forEach { progressHandler.removeCallbacks(it) }
progressRunnables.clear()
}
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
try {
val uri = Uri.parse(urlString)
val request = DownloadManager.Request(uri).apply {
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
setAllowedNetworkTypes(
DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE
)
setAllowedOverMetered(true)
setAllowedOverRoaming(true)
if (destinationPath != null) {
val file = File(destinationPath)
val directory = file.parentFile
if (directory != null && !directory.exists()) {
directory.mkdirs()
}
setDestinationUri(Uri.fromFile(file))
} else {
val fileName = uri.lastPathSegment ?: "download_${System.currentTimeMillis()}"
setDestinationInExternalFilesDir(
context,
null,
fileName
)
}
}
val downloadId = downloadManager.enqueue(request)
downloadTasks[downloadId] = DownloadTaskInfo(
url = urlString,
destinationPath = destinationPath
)
startProgressTracking(downloadId)
sendEvent("onDownloadStarted", mapOf(
"taskId" to downloadId.toInt(),
"url" to urlString
))
promise.resolve(downloadId.toInt())
} catch (e: Exception) {
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
}
}
Function("cancelDownload") { taskId: Int ->
val downloadId = taskId.toLong()
if (downloadTasks.containsKey(downloadId)) {
downloadManager.remove(downloadId)
stopProgressTracking(downloadId)
downloadTasks.remove(downloadId)
}
}
Function("cancelAllDownloads") {
val downloadIds = downloadTasks.keys.toList()
downloadIds.forEach { downloadId ->
downloadManager.remove(downloadId)
stopProgressTracking(downloadId)
}
downloadTasks.clear()
}
AsyncFunction("getActiveDownloads") { promise: Promise ->
try {
val activeDownloads = mutableListOf<Map<String, Any>>()
downloadTasks.forEach { (downloadId, taskInfo) ->
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
activeDownloads.add(mapOf(
"taskId" to downloadId.toInt(),
"url" to taskInfo.url,
"state" to getStateString(status)
))
}
cursor.close()
}
promise.resolve(activeDownloads)
} catch (e: Exception) {
promise.reject("GET_DOWNLOADS_ERROR", "Failed to get active downloads: ${e.message}", e)
}
}
}
private fun registerDownloadReceiver() {
val filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.registerReceiver(
context,
downloadCompleteReceiver,
filter,
ContextCompat.RECEIVER_NOT_EXPORTED
)
} else {
context.registerReceiver(downloadCompleteReceiver, filter)
}
}
private fun unregisterDownloadReceiver() {
try {
context.unregisterReceiver(downloadCompleteReceiver)
} catch (e: IllegalArgumentException) {
// Receiver not registered, ignore
}
}
private fun startProgressTracking(downloadId: Long) {
val runnable = object : Runnable {
override fun run() {
if (!downloadTasks.containsKey(downloadId)) {
return
}
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val bytesDownloaded = if (bytesDownloadedIndex >= 0) cursor.getLong(bytesDownloadedIndex) else 0L
val totalBytesIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val totalBytes = if (totalBytesIndex >= 0) cursor.getLong(totalBytesIndex) else 0L
if (status == DownloadManager.STATUS_RUNNING && totalBytes > 0) {
val progress = bytesDownloaded.toDouble() / totalBytes.toDouble()
sendEvent("onDownloadProgress", mapOf(
"taskId" to downloadId.toInt(),
"bytesWritten" to bytesDownloaded,
"totalBytes" to totalBytes,
"progress" to progress
))
}
// Check for errors
if (status == DownloadManager.STATUS_FAILED) {
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
cursor.close()
stopProgressTracking(downloadId)
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to getErrorString(reason)
))
downloadTasks.remove(downloadId)
return
}
}
cursor.close()
// Continue tracking if still in progress
if (downloadTasks.containsKey(downloadId)) {
progressHandler.postDelayed(this, 500)
}
}
}
progressRunnables[downloadId] = runnable
progressHandler.post(runnable)
}
private fun stopProgressTracking(downloadId: Long) {
progressRunnables[downloadId]?.let { runnable ->
progressHandler.removeCallbacks(runnable)
progressRunnables.remove(downloadId)
}
}
private fun handleDownloadComplete(downloadId: Long) {
stopProgressTracking(downloadId)
val taskInfo = downloadTasks[downloadId]
if (taskInfo == null) {
return
}
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val status = if (statusIndex >= 0) cursor.getInt(statusIndex) else -1
if (status == DownloadManager.STATUS_SUCCESSFUL) {
val uriIndex = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)
val localUri = if (uriIndex >= 0) cursor.getString(uriIndex) else null
if (localUri != null) {
val filePath = Uri.parse(localUri).path ?: localUri
sendEvent("onDownloadComplete", mapOf(
"taskId" to downloadId.toInt(),
"filePath" to filePath,
"url" to taskInfo.url
))
} else {
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to "Could not retrieve downloaded file path"
))
}
} else if (status == DownloadManager.STATUS_FAILED) {
val reasonIndex = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val reason = if (reasonIndex >= 0) cursor.getInt(reasonIndex) else -1
sendEvent("onDownloadError", mapOf(
"taskId" to downloadId.toInt(),
"error" to getErrorString(reason)
))
}
}
cursor.close()
downloadTasks.remove(downloadId)
}
private fun getStateString(status: Int): String {
return when (status) {
DownloadManager.STATUS_RUNNING -> "running"
DownloadManager.STATUS_PAUSED -> "suspended"
DownloadManager.STATUS_PENDING -> "suspended"
DownloadManager.STATUS_SUCCESSFUL -> "completed"
DownloadManager.STATUS_FAILED -> "completed"
else -> "unknown"
}
}
private fun getErrorString(reason: Int): String {
return when (reason) {
DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "No external storage device found"
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists"
DownloadManager.ERROR_FILE_ERROR -> "Storage error"
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient storage space"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP response code"
DownloadManager.ERROR_UNKNOWN -> "Unknown error"
else -> "Download failed (code: $reason)"
}
}
}
data class DownloadTaskInfo(
val url: String,
val destinationPath: String?
)

View File

@@ -1,9 +1,12 @@
{
"name": "background-downloader",
"version": "1.0.0",
"platforms": ["ios"],
"platforms": ["ios", "android"],
"ios": {
"modules": ["BackgroundDownloaderModule"],
"appDelegateSubscribers": ["BackgroundDownloaderAppDelegate"]
},
"android": {
"modules": ["expo.modules.backgrounddownloader.BackgroundDownloaderModule"]
}
}