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

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