From e905737d5b2686cf849affde96c181b0736d06a8 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Fri, 3 Oct 2025 11:15:33 +0200 Subject: [PATCH] wip: android support --- modules/background-downloader/README.md | 37 +- .../android/build.gradle | 46 +++ .../backgrounddownloader/BuildConfig.java | 10 + .../aapt/AndroidManifest.xml | 7 + .../aapt/output-metadata.json | 18 + .../aar-metadata.properties | 6 + .../annotationProcessors.json | 1 + .../debug/generateDebugRFile/R.jar | Bin 0 -> 330 bytes .../debug/generateDebugRFile/R.txt | 0 .../compile-file-map.properties | 1 + .../debug/packageDebugResources/merger.xml | 2 + .../incremental/mergeDebugAssets/merger.xml | 2 + .../mergeDebugJniLibFolders/merger.xml | 2 + .../incremental/mergeDebugShaders/merger.xml | 2 + .../debug/parseDebugLocalResources/R-def.txt | 2 + .../manifest-merger-blame-debug-report.txt | 7 + .../processDebugManifest/AndroidManifest.xml | 7 + .../extractDeepLinksDebug/navigation.json | 1 + .../nestedResourcesValidationReport.txt | 1 + .../generateDebugRFile/package-aware-r.txt | 1 + .../logs/manifest-merger-debug-report.txt | 14 + .../android/src/main/AndroidManifest.xml | 3 + .../BackgroundDownloaderModule.kt | 324 ++++++++++++++++++ .../expo-module.config.json | 5 +- 24 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 modules/background-downloader/android/build.gradle create mode 100644 modules/background-downloader/android/build/generated/source/buildConfig/debug/expo/modules/backgrounddownloader/BuildConfig.java create mode 100644 modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml create mode 100644 modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json create mode 100644 modules/background-downloader/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties create mode 100644 modules/background-downloader/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json create mode 100644 modules/background-downloader/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar create mode 100644 modules/background-downloader/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt create mode 100644 modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties create mode 100644 modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml create mode 100644 modules/background-downloader/android/build/intermediates/incremental/mergeDebugAssets/merger.xml create mode 100644 modules/background-downloader/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml create mode 100644 modules/background-downloader/android/build/intermediates/incremental/mergeDebugShaders/merger.xml create mode 100644 modules/background-downloader/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt create mode 100644 modules/background-downloader/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt create mode 100644 modules/background-downloader/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml create mode 100644 modules/background-downloader/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json create mode 100644 modules/background-downloader/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt create mode 100644 modules/background-downloader/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt create mode 100644 modules/background-downloader/android/build/outputs/logs/manifest-merger-debug-report.txt create mode 100644 modules/background-downloader/android/src/main/AndroidManifest.xml create mode 100644 modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt diff --git a/modules/background-downloader/README.md b/modules/background-downloader/README.md index c3b10ff2..53e419b5 100644 --- a/modules/background-downloader/README.md +++ b/modules/background-downloader/README.md @@ -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 diff --git a/modules/background-downloader/android/build.gradle b/modules/background-downloader/android/build.gradle new file mode 100644 index 00000000..4f1d78fb --- /dev/null +++ b/modules/background-downloader/android/build.gradle @@ -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" + } +} + diff --git a/modules/background-downloader/android/build/generated/source/buildConfig/debug/expo/modules/backgrounddownloader/BuildConfig.java b/modules/background-downloader/android/build/generated/source/buildConfig/debug/expo/modules/backgrounddownloader/BuildConfig.java new file mode 100644 index 00000000..1074b8a6 --- /dev/null +++ b/modules/background-downloader/android/build/generated/source/buildConfig/debug/expo/modules/backgrounddownloader/BuildConfig.java @@ -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"; +} diff --git a/modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml b/modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml new file mode 100644 index 00000000..7a835763 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json b/modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json new file mode 100644 index 00000000..0addb52c --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/aapt_friendly_merged_manifests/debug/processDebugManifest/aapt/output-metadata.json @@ -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" +} diff --git a/modules/background-downloader/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties b/modules/background-downloader/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties new file mode 100644 index 00000000..1211b1ef --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/aar_metadata/debug/writeDebugAarMetadata/aar-metadata.properties @@ -0,0 +1,6 @@ +aarFormatVersion=1.0 +aarMetadataVersion=1.0 +minCompileSdk=1 +minCompileSdkExtension=0 +minAndroidGradlePluginVersion=1.0.0 +coreLibraryDesugaringEnabled=false diff --git a/modules/background-downloader/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json b/modules/background-downloader/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/annotation_processor_list/debug/javaPreCompileDebug/annotationProcessors.json @@ -0,0 +1 @@ +{} diff --git a/modules/background-downloader/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar b/modules/background-downloader/android/build/intermediates/compile_r_class_jar/debug/generateDebugRFile/R.jar new file mode 100644 index 0000000000000000000000000000000000000000..08e669c5c509d9b15e48fd65aee0e71d730449d5 GIT binary patch literal 330 zcmWIWW@cdk14Rx+rWm^wZPS1}5Y`0Z)QW<9{oMSN(wx*{{iMX??DV4i(!7+E{PMh< z{KS;hBK;t}2?E|VeVc=q9P$ohbI|CylgFsedS)zVUVqUtwe^ORzatS*F zGb00=O=ezZi5(*Yvxa6E4+AR$8y5o$13M!Fi*tTTDuW>d6Oao6j6fDp7D#gfd4fQi z5lFLYZD-)v2$E-D2=HcPVqk`_v3U<7!@#hl5yT?G*$~eLc(byBG=MA&2hw*z90mXj C%Sn>} literal 0 HcmV?d00001 diff --git a/modules/background-downloader/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt b/modules/background-downloader/android/build/intermediates/compile_symbol_list/debug/generateDebugRFile/R.txt new file mode 100644 index 00000000..e69de29b diff --git a/modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties new file mode 100644 index 00000000..baf0a22e --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties @@ -0,0 +1 @@ +#Fri Oct 03 11:14:29 CEST 2025 diff --git a/modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml b/modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml new file mode 100644 index 00000000..9e84793f --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/incremental/debug/packageDebugResources/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/modules/background-downloader/android/build/intermediates/incremental/mergeDebugAssets/merger.xml b/modules/background-downloader/android/build/intermediates/incremental/mergeDebugAssets/merger.xml new file mode 100644 index 00000000..a6cb5856 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/incremental/mergeDebugAssets/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/modules/background-downloader/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml b/modules/background-downloader/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml new file mode 100644 index 00000000..9c8e5811 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/modules/background-downloader/android/build/intermediates/incremental/mergeDebugShaders/merger.xml b/modules/background-downloader/android/build/intermediates/incremental/mergeDebugShaders/merger.xml new file mode 100644 index 00000000..6659d342 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/incremental/mergeDebugShaders/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/modules/background-downloader/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt b/modules/background-downloader/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt new file mode 100644 index 00000000..78ac5b8b --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/local_only_symbol_list/debug/parseDebugLocalResources/R-def.txt @@ -0,0 +1,2 @@ +R_DEF: Internal format may change without notice +local diff --git a/modules/background-downloader/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt b/modules/background-downloader/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt new file mode 100644 index 00000000..442ccc37 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/manifest_merge_blame_file/debug/processDebugManifest/manifest-merger-blame-debug-report.txt @@ -0,0 +1,7 @@ +1 +2 +4 +5 +6 +7 diff --git a/modules/background-downloader/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml b/modules/background-downloader/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml new file mode 100644 index 00000000..7a835763 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/merged_manifest/debug/processDebugManifest/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/modules/background-downloader/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json b/modules/background-downloader/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/navigation_json/debug/extractDeepLinksDebug/navigation.json @@ -0,0 +1 @@ +[] diff --git a/modules/background-downloader/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt b/modules/background-downloader/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt new file mode 100644 index 00000000..08f4ebea --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/nested_resources_validation_report/debug/generateDebugResources/nestedResourcesValidationReport.txt @@ -0,0 +1 @@ +0 Warning/Error \ No newline at end of file diff --git a/modules/background-downloader/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt b/modules/background-downloader/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt new file mode 100644 index 00000000..bcf442d8 --- /dev/null +++ b/modules/background-downloader/android/build/intermediates/symbol_list_with_package_name/debug/generateDebugRFile/package-aware-r.txt @@ -0,0 +1 @@ +expo.modules.backgrounddownloader diff --git a/modules/background-downloader/android/build/outputs/logs/manifest-merger-debug-report.txt b/modules/background-downloader/android/build/outputs/logs/manifest-merger-debug-report.txt new file mode 100644 index 00000000..2c3c07f4 --- /dev/null +++ b/modules/background-downloader/android/build/outputs/logs/manifest-merger-debug-report.txt @@ -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 diff --git a/modules/background-downloader/android/src/main/AndroidManifest.xml b/modules/background-downloader/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..703e8232 --- /dev/null +++ b/modules/background-downloader/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt new file mode 100644 index 00000000..2dae8803 --- /dev/null +++ b/modules/background-downloader/android/src/main/java/expo/modules/backgrounddownloader/BackgroundDownloaderModule.kt @@ -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() + private val progressHandler = Handler(Looper.getMainLooper()) + private val progressRunnables = mutableMapOf() + + 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>() + + 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? +) + diff --git a/modules/background-downloader/expo-module.config.json b/modules/background-downloader/expo-module.config.json index 8138dbd1..f588b229 100644 --- a/modules/background-downloader/expo-module.config.json +++ b/modules/background-downloader/expo-module.config.json @@ -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"] } }