From 95dbcdbb6222e5a3d4cb4115b5ad04dc1deb0963 Mon Sep 17 00:00:00 2001 From: Fredrik Burmester Date: Tue, 18 Feb 2025 16:03:48 +0100 Subject: [PATCH] chore: alt version --- .../hls-downloader/HlsDownloaderModule.alt.kt | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.alt.kt diff --git a/modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.alt.kt b/modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.alt.kt new file mode 100644 index 00000000..580db3fb --- /dev/null +++ b/modules/hls-downloader/android/src/main/java/expo/modules/hls-downloader/HlsDownloaderModule.alt.kt @@ -0,0 +1,189 @@ +package expo.modules.hlsdownloader + +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import androidx.work.* +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.CoroutineWorker +import androidx.work.WorkRequest +import androidx.work.WorkManager +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import kotlinx.coroutines.async +import android.content.pm.ServiceInfo +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import androidx.work.ExistingWorkPolicy +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.net.URL + +class HlsDownloaderModule : Module() { + override fun definition() = ModuleDefinition { + Name("HlsDownloader") + + Events( + "onProgress", + "onComplete", + "onError" + ) + + AsyncFunction("downloadHLSAsset") { id: String, url: String, _: Map -> + val context = appContext.reactContext ?: throw Exception("React context is null") + val data = workDataOf("id" to id, "url" to url) + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val work = OneTimeWorkRequestBuilder() + .setInputData(data) + .setConstraints(constraints) + .addTag(id) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + id, ExistingWorkPolicy.REPLACE, work + ) + } + + AsyncFunction("cancelDownload") { id: String -> + val context = appContext.reactContext ?: throw Exception("React context is null") + WorkManager.getInstance(context).cancelUniqueWork(id) + } + + AsyncFunction("getActiveDownloads") { + emptyList>() + } + } + + private fun createNotification(context: Context): Notification { + val channelId = "hls_download_channel" + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + nm.createNotificationChannel( + NotificationChannel(channelId, "HLS Download", NotificationManager.IMPORTANCE_LOW) + ) + } + return NotificationCompat.Builder(context, channelId) + .setContentTitle("Downloading HLS Stream") + .setContentText("Downloading segments in background") + .setSmallIcon(android.R.drawable.stat_sys_download) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setForegroundServiceType(Notification.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + .build() + } +} + + +class HlsDownloadWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + private val client = OkHttpClient() + + override suspend fun doWork() = coroutineScope { + val id = inputData.getString("id") ?: return@coroutineScope failure() + val url = inputData.getString("url") ?: return@coroutineScope failure() + val downloadDir = File(applicationContext.filesDir, "downloads/$id") + downloadDir.mkdirs() + + // Create ForegroundInfo without the service type + val foregroundInfo = ForegroundInfo( + 1, + createNotification(applicationContext) + ) + setForeground(foregroundInfo) + + try { + // Download master/variant playlist. + val playlistText = client.newCall(Request.Builder().url(url).build()).execute() + .takeIf { it.isSuccessful }?.body?.string() ?: throw Exception("Playlist fetch failed") + val lines = playlistText.lines() + + // Extract segment and key URIs. + val segmentUrls = mutableListOf>() // (orig line, absolute URL) + val keyLines = mutableListOf>() // (orig line, absolute key URL) + lines.forEach { line -> + when { + line.startsWith("#EXT-X-KEY") -> { + Regex("URI=\"(.*?)\"").find(line)?.groupValues?.get(1)?.let { keyUri -> + val absKey = URL(URL(url), keyUri).toString() + keyLines.add(line to absKey) + } + } + line.isNotBlank() && !line.startsWith("#") -> { + val absSegment = URL(URL(url), line).toString() + segmentUrls.add(line to absSegment) + } + } + } + + // Concurrent downloads. + val segResults = segmentUrls.map { (orig, segUrl) -> + async { + val resp = client.newCall(Request.Builder().url(segUrl).build()).execute() + if (!resp.isSuccessful) throw Exception("Segment download failed: $segUrl") + val fileName = segUrl.substringAfterLast("/") + File(downloadDir, fileName).writeBytes(resp.body?.bytes() ?: ByteArray(0)) + orig to fileName + } + } + val keyResults = keyLines.map { (orig, keyUrl) -> + async { + val resp = client.newCall(Request.Builder().url(keyUrl).build()).execute() + if (!resp.isSuccessful) throw Exception("Key download failed: $keyUrl") + val fileName = keyUrl.substringAfterLast("/") + File(downloadDir, fileName).writeBytes(resp.body?.bytes() ?: ByteArray(0)) + orig to fileName + } + } + val segMap = segResults.awaitAll().toMap() + val keyMap = keyResults.awaitAll().toMap() + + // Rebuild playlist with local URIs. + val localPlaylist = lines.joinToString("\n") { line -> + when { + line.isNotBlank() && !line.startsWith("#") -> segMap[line] ?: line + line.startsWith("#EXT-X-KEY") -> + keyMap[line]?.let { localKey -> + line.replace(Regex("URI=\"(.*?)\""), "URI=\"$localKey\"") + } ?: line + else -> line + } + } + File(downloadDir, "local_playlist.m3u8").writeText(localPlaylist) + success() + } catch (e: Exception) { + e.printStackTrace() + failure() + } + } + + private fun createNotification(context: Context): Notification { + val channelId = "hls_download_channel" + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + nm.createNotificationChannel( + NotificationChannel(channelId, "HLS Download", NotificationManager.IMPORTANCE_LOW) + ) + } + return NotificationCompat.Builder(context, channelId) + .setContentTitle("Downloading HLS Stream") + .setContentText("Downloading segments in background") + .setSmallIcon(android.R.drawable.stat_sys_download) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setForegroundServiceType(Notification.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + .build() + } + + private fun success() = Result.success() + private fun failure() = Result.failure() +} \ No newline at end of file