mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-06-29 17:20:30 +01:00
feat: Expo 54 (new arch) support + new in-house download module (#1174)
Signed-off-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: sarendsen <coding-mosses0z@icloud.com> Co-authored-by: Lance Chant <13349722+lancechant@users.noreply.github.com> Co-authored-by: Gauvain <68083474+Gauvino@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
154788cf91
commit
485dc6eeac
@@ -0,0 +1,13 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application>
|
||||
<service
|
||||
android:name=".DownloadService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package expo.modules.backgrounddownloader
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import expo.modules.kotlin.Promise
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
data class DownloadTaskInfo(
|
||||
val url: String,
|
||||
val destinationPath: String?
|
||||
)
|
||||
|
||||
class BackgroundDownloaderModule : Module() {
|
||||
companion object {
|
||||
private const val TAG = "BackgroundDownloader"
|
||||
}
|
||||
|
||||
private val context
|
||||
get() = requireNotNull(appContext.reactContext)
|
||||
|
||||
private val downloadManager = OkHttpDownloadManager()
|
||||
private val downloadTasks = mutableMapOf<Int, DownloadTaskInfo>()
|
||||
private val downloadQueue = mutableListOf<Pair<String, String?>>()
|
||||
private var taskIdCounter = 1
|
||||
private var downloadService: DownloadService? = null
|
||||
private var serviceBound = false
|
||||
|
||||
private val serviceConnection = object : ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
Log.d(TAG, "Service connected")
|
||||
val binder = service as DownloadService.DownloadServiceBinder
|
||||
downloadService = binder.getService()
|
||||
serviceBound = true
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
Log.d(TAG, "Service disconnected")
|
||||
downloadService = null
|
||||
serviceBound = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("BackgroundDownloader")
|
||||
|
||||
Events(
|
||||
"onDownloadProgress",
|
||||
"onDownloadComplete",
|
||||
"onDownloadError",
|
||||
"onDownloadStarted"
|
||||
)
|
||||
|
||||
OnCreate {
|
||||
Log.d(TAG, "Module created")
|
||||
}
|
||||
|
||||
OnDestroy {
|
||||
Log.d(TAG, "Module destroyed")
|
||||
downloadManager.cancelAllDownloads()
|
||||
if (serviceBound) {
|
||||
try {
|
||||
context.unbindService(serviceConnection)
|
||||
serviceBound = false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error unbinding service: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("startDownload") { urlString: String, destinationPath: String?, promise: Promise ->
|
||||
try {
|
||||
val taskId = startDownloadInternal(urlString, destinationPath)
|
||||
promise.resolve(taskId)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("DOWNLOAD_ERROR", "Failed to start download: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
AsyncFunction("enqueueDownload") { urlString: String, destinationPath: String?, promise: Promise ->
|
||||
try {
|
||||
Log.d(TAG, "Enqueuing download: url=$urlString")
|
||||
|
||||
// Add to queue
|
||||
val wasEmpty = downloadQueue.isEmpty()
|
||||
downloadQueue.add(Pair(urlString, destinationPath))
|
||||
Log.d(TAG, "Queue size: ${downloadQueue.size}")
|
||||
|
||||
// If queue was empty and no active downloads, start processing immediately
|
||||
if (wasEmpty && downloadTasks.isEmpty()) {
|
||||
val taskId = processNextInQueue()
|
||||
promise.resolve(taskId)
|
||||
} else {
|
||||
// Return placeholder taskId for queued items
|
||||
promise.resolve(-1)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
promise.reject("DOWNLOAD_ERROR", "Failed to enqueue download: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
Function("cancelDownload") { taskId: Int ->
|
||||
Log.d(TAG, "Cancelling download: taskId=$taskId")
|
||||
downloadManager.cancelDownload(taskId)
|
||||
downloadTasks.remove(taskId)
|
||||
downloadService?.stopDownload()
|
||||
|
||||
// Process next item in queue after cancellation
|
||||
processNextInQueue()
|
||||
}
|
||||
|
||||
Function("cancelQueuedDownload") { url: String ->
|
||||
// Remove from queue by URL
|
||||
downloadQueue.removeAll { queuedItem ->
|
||||
queuedItem.first == url
|
||||
}
|
||||
Log.d(TAG, "Removed queued download: $url, queue size: ${downloadQueue.size}")
|
||||
}
|
||||
|
||||
Function("cancelAllDownloads") {
|
||||
Log.d(TAG, "Cancelling all downloads")
|
||||
downloadManager.cancelAllDownloads()
|
||||
downloadTasks.clear()
|
||||
downloadQueue.clear()
|
||||
stopDownloadService()
|
||||
}
|
||||
|
||||
AsyncFunction("getActiveDownloads") { promise: Promise ->
|
||||
try {
|
||||
val activeDownloads = downloadTasks.map { (taskId, taskInfo) ->
|
||||
mapOf(
|
||||
"taskId" to taskId,
|
||||
"url" to taskInfo.url
|
||||
)
|
||||
}
|
||||
promise.resolve(activeDownloads)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("ERROR", "Failed to get active downloads: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startDownloadInternal(urlString: String, destinationPath: String?): Int {
|
||||
val taskId = taskIdCounter++
|
||||
|
||||
if (destinationPath == null) {
|
||||
throw IllegalArgumentException("Destination path is required")
|
||||
}
|
||||
|
||||
downloadTasks[taskId] = DownloadTaskInfo(
|
||||
url = urlString,
|
||||
destinationPath = destinationPath
|
||||
)
|
||||
|
||||
// Start foreground service if not running
|
||||
startDownloadService()
|
||||
downloadService?.startDownload()
|
||||
|
||||
Log.d(TAG, "Starting download: taskId=$taskId, url=$urlString")
|
||||
|
||||
// Send started event
|
||||
sendEvent("onDownloadStarted", mapOf(
|
||||
"taskId" to taskId,
|
||||
"url" to urlString
|
||||
))
|
||||
|
||||
// Start the download with OkHttp
|
||||
downloadManager.startDownload(
|
||||
taskId = taskId,
|
||||
url = urlString,
|
||||
destinationPath = destinationPath,
|
||||
onProgress = { bytesWritten, totalBytes ->
|
||||
handleProgress(taskId, bytesWritten, totalBytes)
|
||||
},
|
||||
onComplete = { filePath ->
|
||||
handleDownloadComplete(taskId, filePath)
|
||||
},
|
||||
onError = { error ->
|
||||
handleError(taskId, error)
|
||||
}
|
||||
)
|
||||
|
||||
return taskId
|
||||
}
|
||||
|
||||
private fun processNextInQueue(): Int {
|
||||
// Check if queue has items
|
||||
if (downloadQueue.isEmpty()) {
|
||||
Log.d(TAG, "Queue is empty")
|
||||
return -1
|
||||
}
|
||||
|
||||
// Check if there are active downloads (one at a time)
|
||||
if (downloadTasks.isNotEmpty()) {
|
||||
Log.d(TAG, "Active downloads in progress (${downloadTasks.size}), waiting...")
|
||||
return -1
|
||||
}
|
||||
|
||||
// Get next item from queue
|
||||
val (url, destinationPath) = downloadQueue.removeAt(0)
|
||||
Log.d(TAG, "Processing next in queue: $url")
|
||||
|
||||
return try {
|
||||
startDownloadInternal(url, destinationPath)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing queue item: ${e.message}", e)
|
||||
// Try to process next item
|
||||
processNextInQueue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleProgress(taskId: Int, bytesWritten: Long, totalBytes: Long) {
|
||||
val progress = if (totalBytes > 0) {
|
||||
bytesWritten.toDouble() / totalBytes.toDouble()
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
|
||||
// Update notification
|
||||
val taskInfo = downloadTasks[taskId]
|
||||
if (taskInfo != null) {
|
||||
val progressPercent = (progress * 100).toInt()
|
||||
downloadService?.updateProgress("Downloading video", progressPercent)
|
||||
}
|
||||
|
||||
sendEvent("onDownloadProgress", mapOf(
|
||||
"taskId" to taskId,
|
||||
"bytesWritten" to bytesWritten,
|
||||
"totalBytes" to totalBytes,
|
||||
"progress" to progress
|
||||
))
|
||||
}
|
||||
|
||||
private fun handleDownloadComplete(taskId: Int, filePath: String) {
|
||||
val taskInfo = downloadTasks[taskId]
|
||||
|
||||
if (taskInfo == null) {
|
||||
Log.e(TAG, "Download completed but task info not found: taskId=$taskId")
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Download completed: taskId=$taskId, filePath=$filePath")
|
||||
|
||||
sendEvent("onDownloadComplete", mapOf(
|
||||
"taskId" to taskId,
|
||||
"filePath" to filePath,
|
||||
"url" to taskInfo.url
|
||||
))
|
||||
|
||||
downloadTasks.remove(taskId)
|
||||
downloadService?.stopDownload()
|
||||
|
||||
// Process next item in queue
|
||||
processNextInQueue()
|
||||
}
|
||||
|
||||
private fun handleError(taskId: Int, error: String) {
|
||||
val taskInfo = downloadTasks[taskId]
|
||||
|
||||
Log.e(TAG, "Download error: taskId=$taskId, error=$error")
|
||||
|
||||
sendEvent("onDownloadError", mapOf(
|
||||
"taskId" to taskId,
|
||||
"error" to error
|
||||
))
|
||||
|
||||
downloadTasks.remove(taskId)
|
||||
downloadService?.stopDownload()
|
||||
|
||||
// Process next item in queue even on error
|
||||
processNextInQueue()
|
||||
}
|
||||
|
||||
private fun startDownloadService() {
|
||||
if (!serviceBound) {
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
context.startForegroundService(intent)
|
||||
context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopDownloadService() {
|
||||
if (serviceBound && downloadTasks.isEmpty()) {
|
||||
try {
|
||||
context.unbindService(serviceConnection)
|
||||
serviceBound = false
|
||||
downloadService = null
|
||||
|
||||
val intent = Intent(context, DownloadService::class.java)
|
||||
context.stopService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error stopping service: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package expo.modules.backgrounddownloader
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
class DownloadService : Service() {
|
||||
private val TAG = "DownloadService"
|
||||
private val NOTIFICATION_ID = 1001
|
||||
private val CHANNEL_ID = "download_channel"
|
||||
|
||||
private val binder = DownloadServiceBinder()
|
||||
private var activeDownloadCount = 0
|
||||
private var currentDownloadTitle = "Preparing download..."
|
||||
private var currentProgress = 0
|
||||
|
||||
inner class DownloadServiceBinder : Binder() {
|
||||
fun getService(): DownloadService = this@DownloadService
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "DownloadService created")
|
||||
createNotificationChannel()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder {
|
||||
Log.d(TAG, "DownloadService bound")
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.d(TAG, "DownloadService started")
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "DownloadService destroyed")
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Downloads",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Video download progress"
|
||||
setShowBadge(false)
|
||||
}
|
||||
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(): Notification {
|
||||
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(currentDownloadTitle)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
|
||||
if (currentProgress > 0) {
|
||||
builder.setProgress(100, currentProgress, false)
|
||||
.setContentText("$currentProgress% complete")
|
||||
} else {
|
||||
builder.setProgress(100, 0, true)
|
||||
.setContentText("Starting...")
|
||||
}
|
||||
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun startDownload() {
|
||||
activeDownloadCount++
|
||||
Log.d(TAG, "Download started, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 1) {
|
||||
startForeground(NOTIFICATION_ID, createNotification())
|
||||
}
|
||||
}
|
||||
|
||||
fun stopDownload() {
|
||||
activeDownloadCount = maxOf(0, activeDownloadCount - 1)
|
||||
Log.d(TAG, "Download stopped, active count: $activeDownloadCount")
|
||||
if (activeDownloadCount == 0) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProgress(title: String, progress: Int) {
|
||||
currentDownloadTitle = title
|
||||
currentProgress = progress
|
||||
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
notificationManager.notify(NOTIFICATION_ID, createNotification())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package expo.modules.backgrounddownloader
|
||||
|
||||
import android.util.Log
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class OkHttpDownloadManager {
|
||||
private val TAG = "OkHttpDownloadManager"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.callTimeout(0, TimeUnit.SECONDS) // No timeout for long transcodes
|
||||
.build()
|
||||
|
||||
private val activeDownloads = mutableMapOf<Int, Call>()
|
||||
|
||||
fun startDownload(
|
||||
taskId: Int,
|
||||
url: String,
|
||||
destinationPath: String,
|
||||
onProgress: (bytesWritten: Long, totalBytes: Long) -> Unit,
|
||||
onComplete: (filePath: String) -> Unit,
|
||||
onError: (error: String) -> Unit
|
||||
) {
|
||||
Log.d(TAG, "Starting download: taskId=$taskId, url=$url")
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
|
||||
val call = client.newCall(request)
|
||||
activeDownloads[taskId] = call
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.e(TAG, "Download failed: taskId=$taskId, error=${e.message}")
|
||||
activeDownloads.remove(taskId)
|
||||
if (call.isCanceled()) {
|
||||
// Don't report cancellation as error
|
||||
return
|
||||
}
|
||||
onError(e.message ?: "Download failed")
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
if (!response.isSuccessful) {
|
||||
Log.e(TAG, "Download failed with HTTP code: ${response.code}")
|
||||
activeDownloads.remove(taskId)
|
||||
onError("HTTP error: ${response.code} ${response.message}")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val totalBytes = response.body?.contentLength() ?: -1L
|
||||
val inputStream = response.body?.byteStream()
|
||||
|
||||
if (inputStream == null) {
|
||||
activeDownloads.remove(taskId)
|
||||
onError("Failed to get response body")
|
||||
return
|
||||
}
|
||||
|
||||
// Create destination directory if needed
|
||||
val destFile = File(destinationPath)
|
||||
val destDir = destFile.parentFile
|
||||
if (destDir != null && !destDir.exists()) {
|
||||
destDir.mkdirs()
|
||||
}
|
||||
|
||||
val outputStream = destFile.outputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesWritten = 0L
|
||||
var lastProgressUpdate = System.currentTimeMillis()
|
||||
|
||||
inputStream.use { input ->
|
||||
outputStream.use { output ->
|
||||
var bytes = input.read(buffer)
|
||||
while (bytes >= 0) {
|
||||
// Check if download was cancelled
|
||||
if (call.isCanceled()) {
|
||||
Log.d(TAG, "Download cancelled: taskId=$taskId")
|
||||
destFile.delete()
|
||||
activeDownloads.remove(taskId)
|
||||
return
|
||||
}
|
||||
|
||||
output.write(buffer, 0, bytes)
|
||||
bytesWritten += bytes
|
||||
|
||||
// Throttle progress updates to every 500ms
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastProgressUpdate >= 500) {
|
||||
onProgress(bytesWritten, totalBytes)
|
||||
lastProgressUpdate = now
|
||||
}
|
||||
|
||||
bytes = input.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send final progress update
|
||||
onProgress(bytesWritten, totalBytes)
|
||||
|
||||
Log.d(TAG, "Download completed: taskId=$taskId, bytes=$bytesWritten")
|
||||
activeDownloads.remove(taskId)
|
||||
onComplete(destinationPath)
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error during download: taskId=$taskId, error=${e.message}", e)
|
||||
activeDownloads.remove(taskId)
|
||||
|
||||
// Clean up partial file
|
||||
try {
|
||||
File(destinationPath).delete()
|
||||
} catch (deleteError: Exception) {
|
||||
Log.e(TAG, "Failed to delete partial file: ${deleteError.message}")
|
||||
}
|
||||
|
||||
if (!call.isCanceled()) {
|
||||
onError(e.message ?: "Download failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun cancelDownload(taskId: Int) {
|
||||
Log.d(TAG, "Cancelling download: taskId=$taskId")
|
||||
activeDownloads[taskId]?.cancel()
|
||||
activeDownloads.remove(taskId)
|
||||
}
|
||||
|
||||
fun cancelAllDownloads() {
|
||||
Log.d(TAG, "Cancelling all downloads")
|
||||
activeDownloads.values.forEach { it.cancel() }
|
||||
activeDownloads.clear()
|
||||
}
|
||||
|
||||
fun hasActiveDownloads(): Boolean {
|
||||
return activeDownloads.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user