From 9f68bf3124bcae3a7bdf2f6e090ed6dd6ce1890e Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sat, 27 Sep 2025 14:18:45 +0200 Subject: [PATCH] Rewrite download support --- app/build.gradle.kts | 2 + .../5.json | 239 +++++++++++++++ app/src/main/AndroidManifest.xml | 14 +- .../java/org/jellyfin/mobile/MainViewModel.kt | 26 ++ .../mobile/app/ApiClientController.kt | 21 +- .../java/org/jellyfin/mobile/app/AppModule.kt | 14 +- .../org/jellyfin/mobile/app/AppPreferences.kt | 45 +-- .../org/jellyfin/mobile/app/StorageManager.kt | 48 +++ .../jellyfin/mobile/bridge/NativeInterface.kt | 13 +- .../jellyfin/mobile/data/JellyfinDatabase.kt | 10 +- .../jellyfin/mobile/data/dao/DownloadDao.kt | 32 +- .../org/jellyfin/mobile/data/dao/UserDao.kt | 3 + .../mobile/data/entity/DownloadEntity.kt | 94 ++---- .../jellyfin/mobile/downloads/ContentRange.kt | 44 +++ .../downloads/DownloadBroadcastReceiver.kt | 43 +++ .../mobile/downloads/DownloadManager.kt | 118 ++++++++ .../mobile/downloads/DownloadMethod.java | 14 - .../mobile/downloads/DownloadMethod.kt | 14 + .../downloads/DownloadNotificationManager.kt | 107 +++++++ .../mobile/downloads/DownloadQueue.kt | 101 +++++++ .../mobile/downloads/DownloadServiceUtil.kt | 61 ---- .../mobile/downloads/DownloadStatus.kt | 9 + .../mobile/downloads/DownloadTracker.kt | 81 ----- .../mobile/downloads/DownloadUtils.kt | 282 ------------------ .../mobile/downloads/DownloadWorker.kt | 69 +++++ .../mobile/downloads/DownloadsViewModel.kt | 65 +++- .../mobile/downloads/FileDownloader.kt | 125 ++++++++ .../downloads/JellyfinDownloadService.kt | 101 ------- .../jellyfin/mobile/events/ActivityEvent.kt | 8 +- .../mobile/events/ActivityEventHandler.kt | 11 +- .../interaction/PlayerNotificationHelper.kt | 16 +- .../mobile/player/queue/QueueManager.kt | 50 +++- .../source/JellyfinMediaSourceSerializer.kt | 78 ----- .../player/source/LocalJellyfinMediaSource.kt | 15 - .../mobile/settings/SettingsFragment.kt | 48 +-- .../ui/screens/downloads/DownloadsList.kt | 158 +++++++++- .../org/jellyfin/mobile/utils/Constants.kt | 4 +- .../mobile/utils/DocumentFileExtensions.kt | 13 + .../org/jellyfin/mobile/utils/SystemUtils.kt | 123 ++------ .../jellyfin/mobile/utils/extensions/Long.kt | 21 -- .../mobile/webapp/JellyfinWebViewClient.kt | 6 +- .../jellyfin/mobile/webapp/WebViewFragment.kt | 7 +- app/src/main/res/values/strings.xml | 10 +- gradle/libs.versions.toml | 4 + 44 files changed, 1393 insertions(+), 974 deletions(-) create mode 100644 app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/5.json create mode 100644 app/src/main/java/org/jellyfin/mobile/app/StorageManager.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/ContentRange.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadBroadcastReceiver.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadManager.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadNotificationManager.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadQueue.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadStatus.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadWorker.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/FileDownloader.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/utils/DocumentFileExtensions.kt delete mode 100644 app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9c48410..f1a98fd1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -121,6 +121,8 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.androidx.activity) implementation(libs.androidx.fragment) + implementation(libs.androidx.documentfile) + implementation(libs.androidx.work.runtime) coreLibraryDesugaring(libs.androiddesugarlibs) // Lifecycle diff --git a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/5.json b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/5.json new file mode 100644 index 00000000..9e6b3556 --- /dev/null +++ b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/5.json @@ -0,0 +1,239 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "bb9e921c1ce696cf9a83c92fff1903e1", + "entities": [ + { + "tableName": "server", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hostname` TEXT NOT NULL, `last_used_timestamp` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hostname", + "columnName": "hostname", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUsedTimestamp", + "columnName": "last_used_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_server_hostname", + "unique": true, + "columnNames": [ + "hostname" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_server_hostname` ON `${TABLE_NAME}` (`hostname`)" + } + ] + }, + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `access_token` TEXT, `last_login_timestamp` INTEGER NOT NULL, FOREIGN KEY(`server_id`) REFERENCES `server`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "access_token", + "affinity": "TEXT" + }, + { + "fieldPath": "lastLoginTimestamp", + "columnName": "last_login_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_user_server_id_user_id", + "unique": true, + "columnNames": [ + "server_id", + "user_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_server_id_user_id` ON `${TABLE_NAME}` (`server_id`, `user_id`)" + } + ], + "foreignKeys": [ + { + "table": "server", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "server_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `user_id` INTEGER NOT NULL, `item_id` TEXT NOT NULL, `path` TEXT NOT NULL, `item` TEXT NOT NULL, `status` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `modified_at` INTEGER NOT NULL, FOREIGN KEY(`server_id`) REFERENCES `server`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "server_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "itemId", + "columnName": "item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "item", + "columnName": "item", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifiedAt", + "columnName": "modified_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_download_server_id", + "unique": false, + "columnNames": [ + "server_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_server_id` ON `${TABLE_NAME}` (`server_id`)" + }, + { + "name": "index_download_user_id", + "unique": false, + "columnNames": [ + "user_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_user_id` ON `${TABLE_NAME}` (`user_id`)" + }, + { + "name": "index_download_item_id", + "unique": false, + "columnNames": [ + "item_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_download_item_id` ON `${TABLE_NAME}` (`item_id`)" + } + ], + "foreignKeys": [ + { + "table": "server", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "server_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bb9e921c1ce696cf9a83c92fff1903e1')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e4d28642..3fad6f35 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -79,15 +79,9 @@ android:foregroundServiceType="mediaPlayback" /> - - - - - - + android:name="androidx.work.impl.foreground.SystemForegroundService" + android:foregroundServiceType="dataSync" + tools:node="merge" /> + + diff --git a/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt index 5d8348ea..185ef6a6 100644 --- a/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/MainViewModel.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import org.jellyfin.mobile.app.ApiClientController import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.data.entity.UserEntity +import java.util.UUID class MainViewModel( app: Application, @@ -16,15 +18,25 @@ class MainViewModel( private val _serverState: MutableStateFlow = MutableStateFlow(ServerState.Pending) val serverState: StateFlow get() = _serverState + private val _userState: MutableStateFlow = MutableStateFlow(UserState.Pending) + val userState: StateFlow get() = _userState + init { viewModelScope.launch { refreshServer() + refreshUser() } } suspend fun switchServer(hostname: String) { apiClientController.setupServer(hostname) refreshServer() + refreshUser() + } + + suspend fun setupUser(serverId: Long, userId: UUID, accessToken: String) { + apiClientController.setupUser(serverId, userId, accessToken) + refreshUser() } private suspend fun refreshServer() { @@ -32,11 +44,17 @@ class MainViewModel( _serverState.value = serverEntity?.let { entity -> ServerState.Available(entity) } ?: ServerState.Unset } + private suspend fun refreshUser() { + val userEntity = apiClientController.loadSavedUser() + _userState.value = userEntity?.let { entity -> UserState.Available(entity) } ?: UserState.Unset + } + /** * Temporarily unset the selected server to be able to connect to a different one */ fun resetServer() { _serverState.value = ServerState.Unset + _userState.value = UserState.Unset } } @@ -47,3 +65,11 @@ sealed class ServerState { object Unset : ServerState() class Available(override val server: ServerEntity) : ServerState() } + +sealed class UserState { + open val user: UserEntity? = null + + object Pending : UserState() + object Unset : UserState() + class Available(override val user: UserEntity) : UserState() +} diff --git a/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt index e2a83e12..56f5a3f4 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/ApiClientController.kt @@ -5,6 +5,8 @@ import kotlinx.coroutines.withContext import org.jellyfin.mobile.data.dao.ServerDao import org.jellyfin.mobile.data.dao.UserDao import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.data.entity.ServerUser +import org.jellyfin.mobile.data.entity.UserEntity import org.jellyfin.sdk.Jellyfin import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.DeviceInfo @@ -46,7 +48,12 @@ class ApiClientController( return server } - suspend fun loadSavedServerUser() { + suspend fun loadSavedUser(): UserEntity? = withContext(Dispatchers.IO) { + val userId = appPreferences.currentUserId ?: return@withContext null + userDao.getUser(userId) + } + + suspend fun loadSavedServerUser(): ServerUser? { val serverUser = withContext(Dispatchers.IO) { val serverId = appPreferences.currentServerId ?: return@withContext null val userId = appPreferences.currentUserId ?: return@withContext null @@ -60,6 +67,8 @@ class ApiClientController( } else { resetApiClientUser() } + + return serverUser } suspend fun loadPreviouslyUsedServers(): List = withContext(Dispatchers.IO) { @@ -86,4 +95,14 @@ class ApiClientController( deviceInfo = baseDeviceInfo, ) } + + fun getApiClient(server: Long, user: Long): ApiClient { + val serverUser = userDao.getServerUser(server, user) ?: error("Invalid server user combination (server=$server, user=$user)") + + return jellyfin.createApi( + baseUrl = serverUser.server.hostname, + accessToken = serverUser.user.accessToken, + deviceInfo = baseDeviceInfo.copy(id = baseDeviceInfo.id + serverUser.user.userId), + ) + } } diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt index a7eecd20..ad4f777f 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppModule.kt @@ -21,13 +21,18 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ts.TsExtractor +import androidx.work.WorkManager import coil3.ImageLoader import kotlinx.coroutines.channels.Channel import okhttp3.OkHttpClient import org.jellyfin.mobile.MainViewModel import org.jellyfin.mobile.bridge.MediaSegments import org.jellyfin.mobile.bridge.NativePlayer +import org.jellyfin.mobile.downloads.DownloadManager +import org.jellyfin.mobile.downloads.DownloadNotificationManager +import org.jellyfin.mobile.downloads.DownloadQueue import org.jellyfin.mobile.downloads.DownloadsViewModel +import org.jellyfin.mobile.downloads.FileDownloader import org.jellyfin.mobile.events.ActivityEventHandler import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder import org.jellyfin.mobile.player.interaction.PlayerEvent @@ -62,6 +67,7 @@ val applicationModule = module { single { PermissionRequestHelper() } single { RemoteVolumeProvider(get()) } single(named(PLAYER_EVENT_CHANNEL)) { Channel() } + factory { WorkManager.getInstance(get()) } // Controllers single { ApiClientController(get(), get(), get(), get(), get()) } @@ -145,7 +151,7 @@ val applicationModule = module { .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) .setCacheWriteDataSinkFactory(null) .setCacheKeyFactory { spec -> - spec.uri.extractId() + spec.key ?: spec.uri.extractId() } } @@ -165,4 +171,10 @@ val applicationModule = module { single { ProgressiveMediaSource.Factory(get()) } single { HlsMediaSource.Factory(get()) } single { SingleSampleMediaSource.Factory(get()) } + + single(createdAtStart = true) { StorageManager(get(), get()) } + single { DownloadManager(get(), get(), get(), get(), get()) } + single { DownloadNotificationManager(get()) } + single { DownloadQueue(get(), get(), get(), get(), get(), get()) } + single { FileDownloader(get()) } } diff --git a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt index 9e2d166e..bac6ccd7 100644 --- a/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt +++ b/app/src/main/java/org/jellyfin/mobile/app/AppPreferences.kt @@ -2,16 +2,15 @@ package org.jellyfin.mobile.app import android.content.Context import android.content.SharedPreferences -import android.os.Environment import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE import androidx.core.content.edit +import org.jellyfin.mobile.downloads.DownloadMethod import org.jellyfin.mobile.player.mediasegments.MediaSegmentAction import org.jellyfin.mobile.player.mediasegments.toMediaSegmentActionsString import org.jellyfin.mobile.settings.ExternalPlayerPackage import org.jellyfin.mobile.settings.VideoPlayerType import org.jellyfin.mobile.utils.Constants import org.jellyfin.sdk.model.api.MediaSegmentType -import java.io.File class AppPreferences(context: Context) { private val sharedPreferences: SharedPreferences = @@ -57,48 +56,24 @@ class AppPreferences(context: Context) { } } - var downloadMethod: Int? - get() = sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1).takeIf { it >= 0 } + var downloadMethod: DownloadMethod? + get() = DownloadMethod.fromInt(sharedPreferences.getInt(Constants.PREF_DOWNLOAD_METHOD, -1)) set(value) { if (value != null) { sharedPreferences.edit { - putInt(Constants.PREF_DOWNLOAD_METHOD, value) + putInt(Constants.PREF_DOWNLOAD_METHOD, value.intValue) } } } - var downloadLocation: String - get() { - val savedStorage = sharedPreferences.getString(Constants.PREF_DOWNLOAD_LOCATION, null) - if (savedStorage != null) { - if (File(savedStorage).parentFile?.isDirectory == true) { - // Saved location is still valid - return savedStorage - } else { - // Reset download option if corrupt - sharedPreferences.edit { - remove(Constants.PREF_DOWNLOAD_LOCATION) - } - } - } - - // Return default storage location - return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath - } + var storageLocation: String? + get() = sharedPreferences.getString(Constants.PREF_STORAGE_LOCATION, null) set(value) { sharedPreferences.edit { - if (File(value).parentFile?.isDirectory == true) { - putString(Constants.PREF_DOWNLOAD_LOCATION, value) - } - } - } - - var downloadToInternal: Boolean? - get() = sharedPreferences.getBoolean(Constants.PREF_DOWNLOAD_INTERNAL, true) - set(value) { - if (value != null) { - sharedPreferences.edit { - putBoolean(Constants.PREF_DOWNLOAD_METHOD, value) + if (value == null) { + remove(Constants.PREF_STORAGE_LOCATION) + } else { + putString(Constants.PREF_STORAGE_LOCATION, value) } } } diff --git a/app/src/main/java/org/jellyfin/mobile/app/StorageManager.kt b/app/src/main/java/org/jellyfin/mobile/app/StorageManager.kt new file mode 100644 index 00000000..43b24c01 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/app/StorageManager.kt @@ -0,0 +1,48 @@ +package org.jellyfin.mobile.app + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import org.jellyfin.mobile.R +import java.io.File + +class StorageManager( + private val context: Context, + private val appPreferences: AppPreferences, +) { + private val defaultStorageLocation + get() = Environment.getExternalStorageDirectory().absolutePath + File.separator + context.getString(R.string.app_name_short) + + init { + ensureNoMedia(getStorageLocation()) + } + + fun getStorageLocation(): DocumentFile = appPreferences.storageLocation?.toUri()?.let { + DocumentFile.fromTreeUri(context, it) + } ?: DocumentFile.fromFile(File(defaultStorageLocation)) + + fun changeStorageLocation(location: Uri) { + if (appPreferences.storageLocation?.toUri() == location) return + + val documentFile = DocumentFile.fromTreeUri(context, location) ?: error("Invalid location $location") + context.contentResolver.takePersistableUriPermission( + documentFile.uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION, + ) + ensureNoMedia(documentFile) + appPreferences.storageLocation = documentFile.uri.toString() + } + + private fun ensureNoMedia(documentFile: DocumentFile) { + if (documentFile.findFile(NOMEDIA_FILE) == null) { + documentFile.createFile("", NOMEDIA_FILE) + } + } + + companion object { + const val NOMEDIA_FILE = ".nomedia" + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt index e67f3306..c0cc7c07 100644 --- a/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt +++ b/app/src/main/java/org/jellyfin/mobile/bridge/NativeInterface.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.media.session.PlaybackState import android.webkit.JavascriptInterface import androidx.core.content.ContextCompat -import androidx.core.net.toUri import org.jellyfin.mobile.events.ActivityEvent import org.jellyfin.mobile.events.ActivityEventHandler import org.jellyfin.mobile.utils.Constants @@ -25,6 +24,7 @@ import org.jellyfin.mobile.webapp.RemotePlayerService import org.jellyfin.mobile.webapp.RemoteVolumeProvider import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import org.jellyfin.sdk.model.serializer.toUUID import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -32,6 +32,7 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject import timber.log.Timber +import java.util.UUID @Suppress("unused") class NativeInterface(private val context: Context) : KoinComponent { @@ -126,16 +127,16 @@ class NativeInterface(private val context: Context) : KoinComponent { fun downloadFiles(args: String): Boolean { try { val files = JSONArray(args) + val itemIds = mutableSetOf() repeat(files.length()) { index -> val file = files.getJSONObject(index) + val itemId = file.getString("itemId").toUUID() - val title: String = file.getString("title") - val filename: String = file.getString("filename") - val url: String = file.getString("url") - - emitEvent(ActivityEvent.DownloadFile(url.toUri(), title, filename)) + itemIds.add(itemId) } + + emitEvent(ActivityEvent.DownloadItems(itemIds)) } catch (e: JSONException) { Timber.e("Download failed: %s", e.message) return false diff --git a/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt b/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt index 12bcab37..3013ddf9 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/JellyfinDatabase.kt @@ -8,12 +8,14 @@ import androidx.room.TypeConverter import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec import androidx.sqlite.db.SupportSQLiteDatabase +import kotlinx.serialization.json.Json import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.data.dao.ServerDao import org.jellyfin.mobile.data.dao.UserDao import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.data.entity.ServerEntity import org.jellyfin.mobile.data.entity.UserEntity +import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.serializer.toUUIDOrNull import timber.log.Timber import java.util.UUID @@ -24,7 +26,7 @@ import java.util.UUID UserEntity::class, DownloadEntity::class, ], - version = 4, + version = 5, autoMigrations = [ AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4, spec = JellyfinDatabase.MigrateV4::class), @@ -44,6 +46,12 @@ abstract class JellyfinDatabase : RoomDatabase() { @TypeConverter fun toUuid(value: String?): UUID? = value?.toUUIDOrNull() + + @TypeConverter + fun fromBaseItemDto(baseItem: BaseItemDto?): String? = baseItem?.let(Json::encodeToString) + + @TypeConverter + fun toBaseItemDto(json: String?): BaseItemDto? = json?.let(Json::decodeFromString) } // Migrations diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt index 8bd92601..b7592cd4 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt @@ -4,24 +4,34 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow import org.jellyfin.mobile.data.entity.DownloadEntity -import org.jellyfin.mobile.data.entity.DownloadEntity.Key.TABLE_NAME +import org.jellyfin.sdk.model.UUID @Dao interface DownloadDao { + @Query("SELECT * FROM download ORDER BY created_at DESC") + fun getAllDownloads(): Flow> + + @Query("SELECT * FROM download WHERE status = 'QUEUED' OR status = 'DOWNLOADING' ORDER BY created_at ASC") + fun getQueuedDownloads(): List + + @Query("SELECT * FROM download WHERE item_id IN (:itemIds)") + fun getDownloadsByItemIds(itemIds: Collection): List + + @Query("SELECT * FROM download WHERE item_id = :itemId") + fun getDownloadByItemId(itemId: UUID): DownloadEntity? + + @Query("SELECT * FROM download WHERE id = :id") + suspend fun getDownload(id: Long): DownloadEntity? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: DownloadEntity): Long - @Query("DELETE FROM $TABLE_NAME WHERE item_id LIKE :downloadId") - suspend fun delete(downloadId: String) + @Update(onConflict = OnConflictStrategy.REPLACE) + suspend fun update(entity: DownloadEntity): Int - @Query("SELECT * FROM $TABLE_NAME ORDER BY item_id DESC") - fun getAllDownloads(): Flow> - - @Query("SELECT * FROM $TABLE_NAME WHERE item_id LIKE :downloadId") - suspend fun get(downloadId: String): DownloadEntity? - - @Query("SELECT EXISTS(SELECT * FROM $TABLE_NAME WHERE item_id LIKE :downloadId)") - suspend fun downloadExists(downloadId: String): Boolean + @Query("DELETE FROM download WHERE id = :id") + suspend fun delete(id: Long) } diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt index 033417cf..7a70b324 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/dao/UserDao.kt @@ -44,6 +44,9 @@ interface UserDao { @Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId AND $USER_ID = :userId") fun getByUserId(serverId: Long, userId: UUID): UserEntity? + @Query("SELECT * FROM $TABLE_NAME WHERE $ID = :id") + fun getUser(id: Long): UserEntity? + @Query("SELECT * FROM $TABLE_NAME WHERE $SERVER_ID = :serverId") fun getAllForServer(serverId: Long): List diff --git a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt index 4b1c1552..15e0fead 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt @@ -2,78 +2,44 @@ package org.jellyfin.mobile.data.entity import androidx.room.ColumnInfo import androidx.room.Entity -import androidx.room.Ignore +import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey -import androidx.room.TypeConverter -import androidx.room.TypeConverters -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.Json.Default.decodeFromString -import org.jellyfin.mobile.data.entity.DownloadEntity.Key.ITEM_ID -import org.jellyfin.mobile.data.entity.DownloadEntity.Key.TABLE_NAME -import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource -import org.jellyfin.mobile.utils.extensions.toFileSize -import kotlin.time.Duration +import org.jellyfin.mobile.downloads.DownloadStatus +import org.jellyfin.sdk.model.api.BaseItemDto +import java.util.UUID @Entity( - tableName = TABLE_NAME, - indices = [ - Index(value = [ITEM_ID], unique = true), + tableName = "download", + indices = [Index(value = ["server_id"]), Index(value = ["user_id"]), Index(value = ["item_id"])], + foreignKeys = [ + ForeignKey( + entity = ServerEntity::class, + parentColumns = ["id"], + childColumns = ["server_id"], + onDelete = ForeignKey.CASCADE, + ), + ForeignKey( + entity = UserEntity::class, + parentColumns = ["id"], + childColumns = ["user_id"], + onDelete = ForeignKey.CASCADE, + ), ], ) -@TypeConverters(LocalJellyfinMediaSourceConverter::class) data class DownloadEntity( - @PrimaryKey - @ColumnInfo(name = ITEM_ID) - val itemId: String, - @ColumnInfo(name = MEDIA_SOURCE) - val mediaSource: LocalJellyfinMediaSource, -) { - /** - * Converts the [mediaSource] string to a [LocalJellyfinMediaSource] object. - * - * @param startTime The start time as a [Duration]. If null, the default start time is used. - * @param audioStreamIndex The index of the audio stream to select. If null, the default audio stream is used. - * @param subtitleStreamIndex The index of the subtitle stream to select. If -1, subtitles are disabled. If null, the default subtitle stream is used. - */ - fun asMediaSource( - startTime: Duration? = null, - audioStreamIndex: Int? = null, - subtitleStreamIndex: Int? = null, - ): LocalJellyfinMediaSource = mediaSource - .also { localJellyfinMediaSource -> - startTime - ?.let { localJellyfinMediaSource.startTime = it } - audioStreamIndex - ?.let { localJellyfinMediaSource.mediaStreams[it] } - ?.let(localJellyfinMediaSource::selectAudioStream) - subtitleStreamIndex - ?.run { - takeUnless { it == -1 } - ?.let { localJellyfinMediaSource.mediaStreams[it] } - ?: localJellyfinMediaSource.selectSubtitleStream(null) - } - } + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "id") val id: Long = 0L, - constructor(mediaSource: LocalJellyfinMediaSource) : - this(mediaSource.id, mediaSource) + @ColumnInfo(name = "server_id") val serverId: Long, + @ColumnInfo(name = "user_id") val userId: Long, + @ColumnInfo(name = "item_id") val itemId: UUID, - @Ignore - val fileSize: String = mediaSource.downloadSize.toFileSize() + @ColumnInfo(name = "path") val path: String, + @ColumnInfo(name = "item") val item: BaseItemDto, - companion object Key { - const val BYTES_PER_BINARY_UNIT: Int = 1024 - const val TABLE_NAME: String = "Download" - const val ID: String = "id" - const val ITEM_ID: String = "item_id" - const val MEDIA_SOURCE: String = "media_source" - } -} + @ColumnInfo(name = "status") val status: DownloadStatus = DownloadStatus.QUEUED, -class LocalJellyfinMediaSourceConverter { - @TypeConverter - fun toLocalJellyfinMediaSource(value: String): LocalJellyfinMediaSource = decodeFromString(value) - - @TypeConverter - fun fromLocalJellyfinMediaSource(value: LocalJellyfinMediaSource): String = Json.encodeToString(value) -} + @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis(), + @ColumnInfo(name = "modified_at") var modifiedAt: Long = System.currentTimeMillis(), +) diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/ContentRange.kt b/app/src/main/java/org/jellyfin/mobile/downloads/ContentRange.kt new file mode 100644 index 00000000..6ae3cf67 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/ContentRange.kt @@ -0,0 +1,44 @@ +package org.jellyfin.mobile.downloads + +data class ContentRange( + val start: Long, + val end: Long, + val total: Long, +) { + companion object { + fun fromContentLengthHeader(input: String): ContentRange { + val value = input.toLongOrNull() + requireNotNull(value) { "Invalid content length $input" } + + return ContentRange(0, value, value) + } + + fun fromContentRangeHeader(input: String): ContentRange { + val parts = input.split(" ") + if (parts.size != 2) error("Invalid formatted content range $input") + + val rangeAndTotal = parts[1].split("/") + + if (rangeAndTotal.size != 2) error("Invalid formatted content range $input") + val rangePart = rangeAndTotal[0] + val totalPart = rangeAndTotal[1] + + val total = totalPart.takeIf { it != "*" }?.toLongOrNull() + requireNotNull(total) { "Total size is missing in content range $input" } + + val (start, end) = when (rangePart) { + "*" -> 0L to 0L + else -> { + val dashParts = rangePart.split("-") + if (dashParts.size != 2) error("Invalid formatted content range $input") + dashParts[0].toLongOrNull() to dashParts[1].toLongOrNull() + } + } + + requireNotNull(start) { "Start is missing in content range $input" } + requireNotNull(end) { "End is missing in content range $input" } + + return ContentRange(start, end, total) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadBroadcastReceiver.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadBroadcastReceiver.kt new file mode 100644 index 00000000..8b8512b4 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadBroadcastReceiver.kt @@ -0,0 +1,43 @@ +package org.jellyfin.mobile.downloads + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class DownloadBroadcastReceiver : BroadcastReceiver(), KoinComponent { + companion object { + private const val ACTION_DOWNLOAD_CANCEL = "download_cancel" + private const val EXTRA_DOWNLOAD_ID = "download_id" + + fun cancelDownloadIntent(context: Context, downloadId: Long) = Intent( + context, + DownloadBroadcastReceiver::class.java, + ).apply { + action = ACTION_DOWNLOAD_CANCEL + putExtra(EXTRA_DOWNLOAD_ID, downloadId) + } + } + + private val downloadManager by inject() + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == ACTION_DOWNLOAD_CANCEL) { + val id = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1L) + if (id == -1L) return + + val pendingResult = goAsync() + CoroutineScope(SupervisorJob()).launch { + try { + downloadManager.cancel(id) + } finally { + pendingResult.finish() + } + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadManager.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadManager.kt new file mode 100644 index 00000000..960b0281 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadManager.kt @@ -0,0 +1,118 @@ +package org.jellyfin.mobile.downloads + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.app.StorageManager +import org.jellyfin.mobile.data.dao.DownloadDao +import org.jellyfin.mobile.data.entity.DownloadEntity +import org.jellyfin.mobile.data.entity.ServerEntity +import org.jellyfin.mobile.data.entity.UserEntity +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.itemsApi +import org.jellyfin.sdk.model.api.ItemFields +import java.util.UUID + +class DownloadManager( + private val context: Context, + private val api: ApiClient, + private val downloadDao: DownloadDao, + private val appPreferences: AppPreferences, + private val storageManager: StorageManager, +) { + companion object { + /** + * How many items can be processed at once in [enqueueItems]. If more items are enqueued at once they will be + * split into separate download chunks. + */ + private const val ITEMS_BATCH = 25 + } + + suspend fun enqueueItems( + server: ServerEntity, + user: UserEntity, + items: Collection, + ) = withContext(Dispatchers.IO) { + for (itemsChunk in items.chunked(ITEMS_BATCH)) { + val existingItems = downloadDao.getDownloadsByItemIds(itemsChunk) + .filter { it.serverId == server.id } + .associateBy { it.itemId } + + val response by api.itemsApi.getItems( + ids = itemsChunk, + fields = setOf(ItemFields.MEDIA_SOURCES, ItemFields.PATH), + ) + + // Sanity check, this shouldn't happen really + if (response.items.size != itemsChunk.size) { + error( + "Requested ${itemsChunk.size} items but only got ${response.items.size}. Indicating one or multiple items do not exist.", + ) + } + + for (item in response.items) { + var downloadEntity = existingItems[item.id] + if (downloadEntity != null) { + // If the item already exists we just update the local information for it and requeue it + // this will force the download worker to recheck the local file in case it is missing or changed + downloadEntity = downloadEntity.copy( + item = item, + status = DownloadStatus.QUEUED, + modifiedAt = System.currentTimeMillis(), + ) + downloadDao.update(downloadEntity) + } else { + // Otherwise we create a new one + downloadEntity = DownloadEntity( + serverId = server.id, + userId = user.id, + itemId = item.id, + item = item, + path = item.name ?: item.id.toString(), + ) + downloadDao.insert(downloadEntity) + } + } + } + + if (!DownloadWorker.isActive(context)) { + DownloadWorker.start(context, appPreferences) + } + } + + suspend fun resume(downloadEntity: DownloadEntity) = withContext(Dispatchers.IO) { + downloadDao.update( + downloadEntity.copy( + status = DownloadStatus.QUEUED, + modifiedAt = System.currentTimeMillis(), + ), + ) + + if (!DownloadWorker.isActive(context)) { + DownloadWorker.start(context, appPreferences) + } + } + + suspend fun cancel(id: Long) = withContext(Dispatchers.IO) { + val download = downloadDao.getDownload(id) + if (download != null) { + DownloadWorker.stop(context) + downloadDao.update(download.copy(status = DownloadStatus.CANCELLED)) + DownloadWorker.start(context, appPreferences) + } + } + + suspend fun delete(id: Long, deleteFiles: Boolean) = withContext(Dispatchers.IO) { + val download = downloadDao.getDownload(id) ?: return@withContext + + if (download.status == DownloadStatus.DOWNLOADING) DownloadWorker.stop(context) + + if (deleteFiles) { + val storageLocation = storageManager.getStorageLocation() + storageLocation.findFile(download.path)?.delete() + } + + downloadDao.delete(id) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java deleted file mode 100644 index d40ec6dc..00000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.jellyfin.mobile.downloads; - -import static org.jellyfin.mobile.downloads.DownloadMethod.MOBILE_AND_ROAMING; -import static org.jellyfin.mobile.downloads.DownloadMethod.MOBILE_DATA; -import static org.jellyfin.mobile.downloads.DownloadMethod.WIFI_ONLY; - -import androidx.annotation.IntDef; - -@IntDef({WIFI_ONLY, MOBILE_DATA, MOBILE_AND_ROAMING}) -public @interface DownloadMethod { - int WIFI_ONLY = 0; - int MOBILE_DATA = 1; - int MOBILE_AND_ROAMING = 2; -} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.kt new file mode 100644 index 00000000..9a7a03e0 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadMethod.kt @@ -0,0 +1,14 @@ +package org.jellyfin.mobile.downloads + +enum class DownloadMethod(val intValue: Int) { + WIFI_ONLY(0), + MOBILE_DATA(1), + MOBILE_AND_ROAMING(2), + ; + + companion object { + val DEFAULT = WIFI_ONLY + + fun fromInt(value: Int): DownloadMethod? = entries.find { it.intValue == value } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadNotificationManager.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadNotificationManager.kt new file mode 100644 index 00000000..71e5b555 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadNotificationManager.kt @@ -0,0 +1,107 @@ +package org.jellyfin.mobile.downloads + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.getSystemService +import androidx.work.ForegroundInfo +import org.jellyfin.mobile.R + +class DownloadNotificationManager( + val context: Context, +) { + companion object { + const val CHANNEL_ID = "downloads" + const val NOTIFICATION_ID = 67 + } + + private val notificationManager = requireNotNull(context.getSystemService()) + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.downloads), + NotificationManager.IMPORTANCE_LOW, + ).apply { + setShowBadge(false) + } + notificationManager.createNotificationChannel(channel) + } + } + + fun createForegroundInfo() = ForegroundInfo( + 67, + NotificationCompat.Builder(context, CHANNEL_ID).apply { + setContentTitle("Downloads") + setSmallIcon(android.R.drawable.stat_sys_download) + }.build(), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0, + ) + + fun downloadFile(id: Long, name: String) = NotificationProgressCallback(context, notificationManager, id, name) +} + +class NotificationProgressCallback( + private val context: Context, + private val notificationManager: NotificationManager, + private val downloadId: Long, + private val name: String, +) : FileDownloader.ProgressCallback { + private var lastProgress = -1 + + private val builder by lazy { + NotificationCompat.Builder(context, DownloadNotificationManager.CHANNEL_ID).apply { + setContentTitle(context.getString(R.string.downloading_title, name)) + setSmallIcon(android.R.drawable.stat_sys_download) + setPriority(NotificationCompat.PRIORITY_LOW) + setOnlyAlertOnce(true) + setOngoing(true) + setProgress(100, 0, true) + + val cancelPendingIntent = PendingIntentCompat.getBroadcast( + context, + 0, + DownloadBroadcastReceiver.cancelDownloadIntent(context, downloadId), + 0, + false, + ) + addAction( + NotificationCompat.Action.Builder( + null, + context.getString(R.string.download_cancel), + cancelPendingIntent, + ).build(), + ) + } + } + + override suspend fun onProgress(downloaded: Long, total: Long) { + val progress = (downloaded.toFloat() / (total.toFloat()) * 100).toInt().coerceIn(0, 100) + + if (lastProgress == progress) return + lastProgress = progress + + if (progress == 100) { + builder.apply { + setContentText(context.getString(R.string.download_completed)) + setProgress(0, 0, false) + setSmallIcon(android.R.drawable.stat_sys_download_done) + } + } else { + builder.apply { + setSubText(context.getString(R.string.download_progress, progress)) + setProgress(100, progress, false) + setOngoing(false) + } + } + + notificationManager.notify(DownloadNotificationManager.NOTIFICATION_ID, builder.build()) + } + + suspend fun onEnd() = onProgress(Long.MAX_VALUE, Long.MAX_VALUE) +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadQueue.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadQueue.kt new file mode 100644 index 00000000..43a333ca --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadQueue.kt @@ -0,0 +1,101 @@ +package org.jellyfin.mobile.downloads + +import android.content.Context +import androidx.core.net.toUri +import androidx.documentfile.provider.DocumentFile +import kotlinx.coroutines.CancellationException +import okhttp3.OkHttpClient +import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.app.StorageManager +import org.jellyfin.mobile.data.dao.DownloadDao +import org.jellyfin.mobile.data.entity.DownloadEntity +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi +import org.jellyfin.sdk.model.api.BaseItemDto + +class DownloadQueue( + private val context: Context, + private val apiClientController: ApiClientController, + private val downloadDao: DownloadDao, + private val downloadNotificationManager: DownloadNotificationManager, + private val storageManager: StorageManager, + okHttpClient: OkHttpClient, +) { + private val _downloader = FileDownloader(okHttpClient) + private val _downloads = mutableListOf() + + suspend fun prepare(): Boolean { + val queuedDownloads = downloadDao.getQueuedDownloads() + _downloads.clear() + _downloads.addAll(queuedDownloads) + return _downloads.any() + } + + suspend fun process() { + while (_downloads.any()) { + val iterator = _downloads.iterator() + while (iterator.hasNext()) { + val download = iterator.next() + process(download) + iterator.remove() + } + + // Refetch the queued downloads + prepare() + } + } + + private suspend fun process(download: DownloadEntity) { + // Mark as downloading + downloadDao.update(download.copy(status = DownloadStatus.DOWNLOADING)) + + try { + val notificationProgressCallback = downloadNotificationManager.downloadFile( + download.id, + download.id.toString(), + ) + + val api = apiClientController.getApiClient(download.serverId, download.userId) + + val storageLocation = storageManager.getStorageLocation() + val itemLocation = storageLocation.findFile(download.path) ?: storageLocation.createDirectory(download.path) ?: error("Unable to find or create folder ${download.path}") + + // TODO: Download all mediastreams, thumbnail etc. + download( + api = api, + item = download.item, + itemLocation = itemLocation, + progressCallback = notificationProgressCallback, + ) + + notificationProgressCallback.onEnd() + downloadDao.update(download.copy(status = DownloadStatus.DOWNLOADED)) + } catch (_: CancellationException) { + downloadDao.update(download.copy(status = DownloadStatus.QUEUED)) + } catch (error: Throwable) { + downloadDao.update(download.copy(status = DownloadStatus.ERROR)) + throw error + } + } + + private suspend fun download( + api: ApiClient, + item: BaseItemDto, + itemLocation: DocumentFile, + progressCallback: FileDownloader.ProgressCallback, + ) { + val filename = item.path?.replace(Regex("^.*[\\\\/]"), "") ?: error("Missing item path") + + val fileLocation = itemLocation.findFile(filename) ?: itemLocation.createFile("", filename) ?: error("Unable to find or create file $filename") + if (!fileLocation.canRead() || !fileLocation.canWrite()) error("Not allowed to read-write $fileLocation") + + val fileDescriptor = context.contentResolver.openFileDescriptor(fileLocation.uri, "rw") ?: error("Unable to open file descriptor for $fileLocation") + + _downloader.downloadAndSave( + api, + from = api.libraryApi.getDownloadUrl(item.id).toUri(), + to = fileDescriptor, + progressCallback = progressCallback, + ) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt deleted file mode 100644 index 5552bc48..00000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt +++ /dev/null @@ -1,61 +0,0 @@ -package org.jellyfin.mobile.downloads - -import android.content.Context -import androidx.media3.database.DatabaseProvider -import androidx.media3.datasource.DataSource -import androidx.media3.datasource.cache.Cache -import androidx.media3.exoplayer.offline.DownloadManager -import androidx.media3.exoplayer.offline.DownloadNotificationHelper -import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject -import java.util.concurrent.Executors - -object DownloadServiceUtil : KoinComponent { - private const val DOWNLOAD_THREADS = 6 - - private val context: Context by inject() - private val databaseProvider: DatabaseProvider by inject() - private val downloadCache: Cache by inject() - private val dataSourceFactory: DataSource.Factory by inject() - private var downloadManager: DownloadManager? = null - private var downloadNotificationHelper: DownloadNotificationHelper? = null - private var downloadTracker: DownloadTracker? = null - - @Synchronized - fun getDownloadNotificationHelper( - context: Context?, - ): DownloadNotificationHelper { - if (downloadNotificationHelper == null) { - downloadNotificationHelper = - DownloadNotificationHelper(context!!, DOWNLOAD_NOTIFICATION_CHANNEL_ID) - } - return downloadNotificationHelper!! - } - - @Synchronized - fun getDownloadManager(): DownloadManager { - ensureDownloadManagerInitialized(context) - return downloadManager!! - } - - @Synchronized - fun getDownloadTracker(): DownloadTracker { - ensureDownloadManagerInitialized(context) - return downloadTracker!! - } - - @Synchronized - private fun ensureDownloadManagerInitialized(context: Context) { - if (downloadManager == null) { - downloadManager = DownloadManager( - context, - databaseProvider, - downloadCache, - dataSourceFactory, - Executors.newFixedThreadPool(DOWNLOAD_THREADS), - ) - downloadTracker = DownloadTracker(downloadManager!!) - } - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadStatus.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadStatus.kt new file mode 100644 index 00000000..8aa3af70 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadStatus.kt @@ -0,0 +1,9 @@ +package org.jellyfin.mobile.downloads + +enum class DownloadStatus { + QUEUED, + DOWNLOADING, + DOWNLOADED, + ERROR, + CANCELLED, +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt deleted file mode 100644 index 423961d4..00000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt +++ /dev/null @@ -1,81 +0,0 @@ -package org.jellyfin.mobile.downloads - -import android.net.Uri -import androidx.media3.exoplayer.offline.Download -import androidx.media3.exoplayer.offline.DownloadIndex -import androidx.media3.exoplayer.offline.DownloadManager -import com.google.common.base.Preconditions -import timber.log.Timber -import java.io.IOException -import java.util.concurrent.CopyOnWriteArraySet - -class DownloadTracker(downloadManager: DownloadManager) { - interface Listener { - fun onDownloadsChanged() - } - - private val listeners: CopyOnWriteArraySet = CopyOnWriteArraySet() - private val downloads: HashMap = HashMap() - private val downloadIndex: DownloadIndex = downloadManager.downloadIndex - - init { - downloadManager.addListener(DownloadManagerListener()) - loadDownloads() - } - - fun addListener(listener: Listener?) { - listeners.add(Preconditions.checkNotNull(listener)) - } - - fun removeListener(listener: Listener) { - listeners.remove(listener) - } - - fun isDownloaded(uri: Uri): Boolean { - val download = downloads[uri] - return download != null && download.state == Download.STATE_COMPLETED - } - - fun getDownloadSize(uri: Uri): Long { - val download = downloads[uri] - return download?.bytesDownloaded ?: 0 - } - - fun isFailed(uri: Uri): Boolean { - val download = downloads[uri] - return download != null && download.state == Download.STATE_FAILED - } - - private fun loadDownloads() { - try { - downloadIndex.getDownloads().use { loadedDownloads -> - while (loadedDownloads.moveToNext()) { - val download = loadedDownloads.download - downloads[download.request.uri] = download - } - } - } catch (e: IOException) { - Timber.e(e, "Failed to load downloads") - } - } - - private inner class DownloadManagerListener : DownloadManager.Listener { - override fun onDownloadChanged( - downloadManager: DownloadManager, - download: Download, - finalException: Exception?, - ) { - downloads[download.request.uri] = download - for (listener in listeners) { - listener.onDownloadsChanged() - } - } - - override fun onDownloadRemoved(downloadManager: DownloadManager, download: Download) { - downloads.remove(download.request.uri) - for (listener in listeners) { - listener.onDownloadsChanged() - } - } - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt deleted file mode 100644 index 30c153be..00000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt +++ /dev/null @@ -1,282 +0,0 @@ -package org.jellyfin.mobile.downloads - -import android.Manifest.permission.WRITE_EXTERNAL_STORAGE -import android.annotation.SuppressLint -import android.app.DownloadManager -import android.app.NotificationChannel -import android.app.NotificationManager -import android.content.Context -import android.content.pm.PackageManager.PERMISSION_GRANTED -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.Uri -import android.os.Build -import android.os.Build.VERSION_CODES.P -import android.util.AndroidException -import androidx.annotation.RequiresApi -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import androidx.media3.exoplayer.offline.DownloadRequest -import androidx.media3.exoplayer.offline.DownloadService -import androidx.media3.exoplayer.scheduler.Requirements -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeout -import org.jellyfin.mobile.MainActivity -import org.jellyfin.mobile.R -import org.jellyfin.mobile.app.AppPreferences -import org.jellyfin.mobile.data.dao.DownloadDao -import org.jellyfin.mobile.data.entity.DownloadEntity -import org.jellyfin.mobile.downloads.DownloadServiceUtil.getDownloadTracker -import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder -import org.jellyfin.mobile.player.source.JellyfinMediaSource -import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource -import org.jellyfin.mobile.player.source.MediaSourceResolver -import org.jellyfin.mobile.utils.AndroidVersion -import org.jellyfin.mobile.utils.Constants -import org.jellyfin.mobile.utils.extractId -import org.jellyfin.mobile.utils.requestPermission -import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.UUID -import org.jellyfin.sdk.model.api.BaseItemKind -import org.jellyfin.sdk.model.serializer.toUUID -import org.koin.core.component.KoinComponent -import org.koin.core.component.get -import org.koin.core.component.inject -import java.io.File -import java.io.IOException -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -class DownloadUtils( - val context: Context, - private val filename: String, - private val downloadURL: String, - private val downloadMethod: Int, -) : KoinComponent { - private val mainActivity: MainActivity = context as MainActivity - private val downloadFolder: File - private val itemId: String - private val itemUUID: UUID - private val contentId: String - private val downloadDao: DownloadDao by inject() - private val apiClient: ApiClient = get() - private val mediaSourceResolver: MediaSourceResolver by inject() - private val deviceProfileBuilder: DeviceProfileBuilder by inject() - private val deviceProfile = deviceProfileBuilder.getDeviceProfile() - private val notificationManager: NotificationManager? by lazy { context.getSystemService() } - private val connectivityManager: ConnectivityManager? by lazy { context.getSystemService() } - private val appPreferences: AppPreferences by inject() - private var downloadTracker: DownloadTracker = getDownloadTracker() - - init { - val regex = Regex("""Items/([a-f0-9]{32})/Download""") - val matchResult = regex.find(downloadURL) - itemId = matchResult?.groups?.get(1)?.value.toString() - itemUUID = itemId.toUUID() - contentId = itemUUID.toString() - downloadFolder = File(context.filesDir, "/Downloads/$itemId/") - downloadFolder.mkdirs() - } - - suspend fun download() { - createDownloadNotificationChannel() - checkForDownloadMethod() - val jellyfinMediaSource = retrieveJellyfinMediaSource() - val isDestinationInternal = jellyfinMediaSource.getIsDestinationInternal() - if (isDestinationInternal) { - if (checkIfDownloadExists(itemId)) { - removeDownloadRemains(jellyfinMediaSource) - } else { - downloadFiles(jellyfinMediaSource) - } - } else { - downloadExternalMediaFile(jellyfinMediaSource) - } - } - - @SuppressLint("InlinedApi") - private fun checkForDownloadMethod() { - val validConnection = when (downloadMethod) { - DownloadMethod.WIFI_ONLY -> { - setUnmeteredRequirement() - !isNetworkMetered() - } - DownloadMethod.MOBILE_DATA -> { - if (Build.VERSION.SDK_INT < P) { - !isNetworkRoamingCompat() - } else { - !isNetworkRoaming() - } - } - else -> true - } - - if (!validConnection) throw IOException(context.getString(R.string.failed_network_method_check)) - } - - private fun setUnmeteredRequirement() { - DownloadService.sendSetRequirements( - context, - JellyfinDownloadService::class.java, - Requirements(Requirements.NETWORK_UNMETERED), - false, - ) - } - - private fun isNetworkMetered(): Boolean = connectivityManager?.isActiveNetworkMetered == true - - private fun isNetworkRoamingCompat(): Boolean = connectivityManager?.activeNetworkInfo?.isRoaming ?: throw AndroidException() - - @RequiresApi(P) - private fun isNetworkRoaming(): Boolean { - val network: Network = connectivityManager?.activeNetwork ?: throw AndroidException() - val capabilities: NetworkCapabilities = connectivityManager?.getNetworkCapabilities(network) ?: throw AndroidException() - return !capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_ROAMING) - } - - private suspend fun checkIfDownloadExists(itemId: String) = downloadDao.downloadExists(itemId) - - private suspend fun retrieveJellyfinMediaSource() = mediaSourceResolver.resolveMediaSource( - itemId = itemUUID, - mediaSourceId = itemId, - deviceProfile = deviceProfile, - ).getOrElse { throw IOException(context.getString(R.string.failed_information)) } - - // Only download shows and movies to internal storage - private fun JellyfinMediaSource.getIsDestinationInternal() = - appPreferences.downloadToInternal == true && item?.type in listOf( - BaseItemKind.EPISODE, - BaseItemKind.MOVIE, - BaseItemKind.VIDEO, - BaseItemKind.MUSIC_VIDEO, - ) - - private fun downloadFiles(jellyfinMediaSource: JellyfinMediaSource) { - val jellyfinDownloadTracker = JellyfinDownloadTracker(jellyfinMediaSource) - downloadTracker.addListener(jellyfinDownloadTracker) - downloadMediaFile(jellyfinMediaSource) - downloadExternalSubtitles(jellyfinMediaSource) - } - - private fun downloadMediaFile(jellyfinMediaSource: JellyfinMediaSource) { - val downloadUri = downloadURL.toUri() - val cacheKey = downloadURL.toUri().extractId() - - val downloadRequest = DownloadRequest.Builder(contentId, downloadUri) - .setData(jellyfinMediaSource.item!!.name!!.encodeToByteArray()) - .setCustomCacheKey(cacheKey) - .build() - DownloadService.sendAddDownload( - context, - JellyfinDownloadService::class.java, - downloadRequest, - false, - ) - } - - private fun downloadExternalSubtitles(jellyfinMediaSource: JellyfinMediaSource) { - jellyfinMediaSource.externalSubtitleStreams.forEach { - val subtitleDownloadURL = apiClient.createUrl(it.deliveryUrl).toUri() - val subtitleCacheKey: String = subtitleDownloadURL.extractId() - - val downloadRequest = DownloadRequest.Builder("$contentId:${it.index}", subtitleDownloadURL) - .setCustomCacheKey(subtitleCacheKey) - .build() - DownloadService.sendAddDownload( - context, - JellyfinDownloadService::class.java, - downloadRequest, - false, - ) - } - } - - private suspend fun storeDownloadSpecs(jellyfinMediaSource: JellyfinMediaSource) = - LocalJellyfinMediaSource( - jellyfinMediaSource, - downloadFolder.canonicalPath, - downloadURL, - downloadTracker.getDownloadSize(downloadURL.toUri()), - ).also { - downloadDao.insert(DownloadEntity(it)) - } - - private suspend fun downloadExternalMediaFile(jellyfinMediaSource: JellyfinMediaSource) { - if (!AndroidVersion.isAtLeastQ) { - @Suppress("MagicNumber") - val granted = withTimeout(2 * 60 * 1000) { - suspendCoroutine { continuation -> - mainActivity.requestPermission(WRITE_EXTERNAL_STORAGE) { requestPermissionsResult -> - continuation.resume(requestPermissionsResult[WRITE_EXTERNAL_STORAGE] == PERMISSION_GRANTED) - } - } - } - - if (!granted) { - throw IOException(context.getString(R.string.download_no_storage_permission)) - } - } - - val downloadRequest = DownloadManager.Request(downloadURL.toUri()) - .setTitle(jellyfinMediaSource.getName(context)) - .setDescription(context.getString(R.string.downloading)) - .setDestinationUri(Uri.fromFile(File(appPreferences.downloadLocation, filename))) - .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - - context.getSystemService()?.enqueue(downloadRequest) - } - - private fun removeDownloadRemains(jellyfinMediaSource: JellyfinMediaSource) { - downloadFolder.deleteRecursively() - - // Remove media file - DownloadService.sendRemoveDownload( - context, - JellyfinDownloadService::class.java, - contentId, - false, - ) - - // Remove subtitles - jellyfinMediaSource.externalSubtitleStreams.forEach { - DownloadService.sendRemoveDownload( - context, - JellyfinDownloadService::class.java, - "$contentId:${it.index}", - false, - ) - } - } - - private fun createDownloadNotificationChannel() { - if (AndroidVersion.isAtLeastO) { - val notificationChannel = NotificationChannel( - Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID, - context.getString(R.string.downloads), - NotificationManager.IMPORTANCE_LOW, - ).apply { - description = context.getString(R.string.download_notifications_description) - } - notificationManager?.createNotificationChannel(notificationChannel) - } - } - - private inner class JellyfinDownloadTracker(val jellyfinMediaSource: JellyfinMediaSource) : DownloadTracker.Listener { - override fun onDownloadsChanged() { - if (downloadTracker.isDownloaded(downloadURL.toUri())) { - runBlocking { - withContext(Dispatchers.IO) { - storeDownloadSpecs(jellyfinMediaSource) - } - } - downloadTracker.removeListener(this) - } else if (downloadTracker.isFailed(downloadURL.toUri())) { - removeDownloadRemains(jellyfinMediaSource) - downloadTracker.removeListener(this) - } - } - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadWorker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadWorker.kt new file mode 100644 index 00000000..ecda4b53 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadWorker.kt @@ -0,0 +1,69 @@ +package org.jellyfin.mobile.downloads + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import org.jellyfin.mobile.app.AppPreferences +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class DownloadWorker( + context: Context, + parameters: WorkerParameters, +) : CoroutineWorker(context, parameters), KoinComponent { + companion object { + private val tag = DownloadWorker::class.qualifiedName!! + + fun start(context: Context, appPreferences: AppPreferences) { + val downloadMethod = appPreferences.downloadMethod ?: DownloadMethod.DEFAULT + + val request = OneTimeWorkRequestBuilder().apply { + addTag(tag) + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + setConstraints( + Constraints.Builder().apply { + when (downloadMethod) { + DownloadMethod.WIFI_ONLY -> setRequiredNetworkType(NetworkType.UNMETERED) + DownloadMethod.MOBILE_DATA -> setRequiredNetworkType(NetworkType.NOT_ROAMING) + DownloadMethod.MOBILE_AND_ROAMING -> setRequiredNetworkType(NetworkType.CONNECTED) + } + }.build(), + ) + }.build() + + WorkManager.getInstance(context).enqueueUniqueWork(tag, ExistingWorkPolicy.REPLACE, request) + } + + fun stop(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(tag) + } + + fun isActive(context: Context): Boolean = WorkManager.getInstance(context) + .getWorkInfosForUniqueWork(tag) + .get() + .any { workInfo -> workInfo.state == WorkInfo.State.RUNNING } + } + + private val downloadNotificationManager by inject() + private val downloadQueue by inject() + + override suspend fun getForegroundInfo(): ForegroundInfo = downloadNotificationManager.createForegroundInfo() + + override suspend fun doWork(): Result { + val canProcess = downloadQueue.prepare() + if (!canProcess) return Result.failure() + + setForeground(getForegroundInfo()) + downloadQueue.process() + + return Result.success() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt index 28729379..5e693f28 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadsViewModel.kt @@ -7,38 +7,77 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.mobile.app.StorageManager import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.events.ActivityEvent import org.jellyfin.mobile.events.ActivityEventHandler import org.jellyfin.mobile.player.interaction.PlayOptions +import org.jellyfin.sdk.model.api.MediaType import org.koin.core.component.KoinComponent import org.koin.core.component.inject class DownloadsViewModel : ViewModel(), KoinComponent { private val downloadDao: DownloadDao by inject() + private val downloadManager: DownloadManager by inject() private val activityEventHandler: ActivityEventHandler by inject() + private val storageManager: StorageManager by inject() val downloads: StateFlow> = downloadDao .getAllDownloads() .flowOn(Dispatchers.IO) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) - fun playDownload(download: DownloadEntity) { - val playOptions = PlayOptions( - ids = listOf(download.mediaSource.itemId), - mediaSourceId = download.mediaSource.id, - startIndex = 0, - startPosition = null, - audioStreamIndex = 1, - subtitleStreamIndex = -1, - playFromDownloads = true, - ) - activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(playOptions)) + fun openDownload(download: DownloadEntity) { + when (download.item.mediaType) { + MediaType.VIDEO, + MediaType.AUDIO, + -> { + val playOptions = PlayOptions( + ids = listOf(download.itemId), + mediaSourceId = download.itemId.toString(), + startIndex = 0, + startPosition = null, + audioStreamIndex = null, + subtitleStreamIndex = null, + playFromDownloads = true, + ) + activityEventHandler.emit(ActivityEvent.LaunchNativePlayer(playOptions)) + } + + MediaType.PHOTO, + MediaType.BOOK, + MediaType.UNKNOWN, + -> { + viewModelScope.launch { + val fileUri = withContext(Dispatchers.IO) { + val storageLocation = storageManager.getStorageLocation() + val itemLocation = storageLocation.findFile(download.path) + if (itemLocation != null && itemLocation.isDirectory) { + val filename = download.item.path?.replace(Regex("^.*[\\\\/]"), "") + if (filename != null) itemLocation.findFile(filename)?.uri else null + } else { + null + } + } + fileUri?.let { activityEventHandler.emit(ActivityEvent.OpenUrl(it.toString(), true)) } + } + } + } } - fun removeDownload(download: DownloadEntity, force: Boolean = false) { - activityEventHandler.emit(ActivityEvent.RemoveDownload(download.mediaSource, force)) + fun download(download: DownloadEntity) { + viewModelScope.launch { + downloadManager.resume(download) + } + } + + fun removeDownload(download: DownloadEntity, deleteFiles: Boolean) { + viewModelScope.launch { + downloadManager.delete(download.id, deleteFiles) + } } } diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/FileDownloader.kt b/app/src/main/java/org/jellyfin/mobile/downloads/FileDownloader.kt new file mode 100644 index 00000000..ed27b5bd --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/FileDownloader.kt @@ -0,0 +1,125 @@ +package org.jellyfin.mobile.downloads + +import android.net.Uri +import android.os.ParcelFileDescriptor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.util.AuthorizationHeaderBuilder +import java.io.IOException +import kotlin.coroutines.resumeWithException + +class FileDownloader( + private val okHttpClient: OkHttpClient, +) { + fun interface ProgressCallback { + suspend fun onProgress(downloaded: Long, total: Long) + + companion object Empty : ProgressCallback { + override suspend fun onProgress(downloaded: Long, total: Long) = Unit + } + } + + private suspend fun download( + api: ApiClient, + from: Uri, + rangeStart: Long? = null, + ): Response { + val authorizationHeader = AuthorizationHeaderBuilder.buildHeader( + clientName = api.clientInfo.name, + clientVersion = api.clientInfo.version, + deviceId = api.deviceInfo.id, + deviceName = api.deviceInfo.name, + accessToken = api.accessToken, + ) + + val request = Request.Builder().apply { + url(from.toString()) + + header("Authorization", authorizationHeader) + rangeStart?.let { header("Range", "bytes=$rangeStart-") } + }.build() + + val response = okHttpClient.newCall(request).await() + + // 416 (Requested Range Not Satisfiable) can happen when we've already fully downloaded the file + if (response.code == 416 && rangeStart != null && rangeStart >= response.getContentRange().total) return response + + // Throw for other unsuccessful responses + if (!response.isSuccessful) throw IOException("Unexpected response $response") + + return response + } + + private suspend fun Call.await(): Response = suspendCancellableCoroutine { continuation -> + enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) { cause, response, _ -> + response.close() + } + } + + override fun onFailure(call: Call, e: IOException) { + continuation.resumeWithException(e) + } + }, + ) + + continuation.invokeOnCancellation { + cancel() + } + } + + private fun Response.getContentRange() = when (code) { + 200 -> requireNotNull(header("Content-Length")).let(ContentRange::fromContentLengthHeader) + 206, 416 -> requireNotNull(header("Content-Range")).let(ContentRange::fromContentRangeHeader) + else -> error("Invalid response code $code") + } + + private suspend fun save( + response: Response, + to: ParcelFileDescriptor, + progressCallback: ProgressCallback, + ) = withContext(Dispatchers.IO) { + val contentRange = response.getContentRange() + + val output = ParcelFileDescriptor.AutoCloseOutputStream(to) + output.channel.position(contentRange.start) + + val inputStream = response.body?.byteStream() ?: error("Response does not contain a body") + inputStream.use { inputStream -> + output.use { outputFile -> + val buffer = ByteArray(10240) + var totalRead = contentRange.start + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } != -1) { + coroutineContext.ensureActive() + + outputFile.write(buffer, 0, bytesRead) + totalRead += bytesRead + + progressCallback.onProgress(totalRead, contentRange.total) + } + } + } + } + + suspend fun downloadAndSave( + api: ApiClient, + from: Uri, + to: ParcelFileDescriptor, + progressCallback: ProgressCallback = ProgressCallback.Empty, + ) { + val rangeStart = to.statSize + val response = download(api, from, rangeStart) + save(response, to, progressCallback) + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt b/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt deleted file mode 100644 index f0811334..00000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/JellyfinDownloadService.kt +++ /dev/null @@ -1,101 +0,0 @@ -package org.jellyfin.mobile.downloads - -import android.app.Notification -import android.content.Context -import androidx.core.app.NotificationCompat -import androidx.media3.common.util.NotificationUtil -import androidx.media3.common.util.Util -import androidx.media3.exoplayer.offline.Download -import androidx.media3.exoplayer.offline.DownloadManager -import androidx.media3.exoplayer.offline.DownloadNotificationHelper -import androidx.media3.exoplayer.offline.DownloadService -import androidx.media3.exoplayer.scheduler.PlatformScheduler -import androidx.media3.exoplayer.scheduler.Scheduler -import org.jellyfin.mobile.R -import org.jellyfin.mobile.utils.Constants -import org.jellyfin.mobile.utils.extensions.toFileSize - -class JellyfinDownloadService : DownloadService( - Constants.DOWNLOAD_NOTIFICATION_ID, - DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL, -) { - private val jobId = 1 - - override fun getDownloadManager(): DownloadManager { - val downloadManager: DownloadManager = DownloadServiceUtil.getDownloadManager() - val downloadNotificationHelper: DownloadNotificationHelper = - DownloadServiceUtil.getDownloadNotificationHelper(this) - downloadManager.addListener( - TerminalStateNotificationHelper( - this, - downloadNotificationHelper, - Constants.DOWNLOAD_NOTIFICATION_ID + 1, - ), - ) - return downloadManager - } - - override fun getScheduler(): Scheduler { - return PlatformScheduler(this, jobId) - } - - @Suppress("MagicNumber") - override fun getForegroundNotification(downloads: MutableList, notMetRequirements: Int): Notification { - val inboxStyle = NotificationCompat.InboxStyle() - - downloads.forEach { download -> - val progress = download.percentDownloaded - inboxStyle.addLine("${Util.fromUtf8Bytes(download.request.data)} - ${progress.toInt()}%") - } - - return NotificationCompat.Builder(this, Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(getString(R.string.downloading)) - .setOngoing(true) - .setOnlyAlertOnce(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setStyle(inboxStyle) - .build() - } - - private class TerminalStateNotificationHelper( - context: Context, - private val notificationHelper: DownloadNotificationHelper, - private var nextNotificationId: Int, - ) : DownloadManager.Listener { - private val context: Context = context.applicationContext - - override fun onDownloadChanged( - downloadManager: DownloadManager, - download: Download, - finalException: Exception?, - ) { - if (download.request.data.isEmpty()) { - // Do not display download complete notification for external subtitles - // Can be identified by request data being empty - return - } - val notification = when (download.state) { - Download.STATE_COMPLETED -> { - NotificationCompat.Builder(context, Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle( - context.getString(R.string.downloaded, Util.fromUtf8Bytes(download.request.data)), - ) - .setContentInfo(download.bytesDownloaded.toFileSize()) - .build() - } - Download.STATE_FAILED -> { - notificationHelper.buildDownloadFailedNotification( - context, - R.drawable.ic_notification, - null, - Util.fromUtf8Bytes(download.request.data), - ) - } - else -> return - } - NotificationUtil.setNotification(context, nextNotificationId++, notification) - } - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt index eccd69c1..22a6b572 100644 --- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEvent.kt @@ -1,16 +1,14 @@ package org.jellyfin.mobile.events -import android.net.Uri import org.jellyfin.mobile.player.interaction.PlayOptions -import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource import org.json.JSONArray +import java.util.UUID sealed class ActivityEvent { class ChangeFullscreen(val isFullscreen: Boolean) : ActivityEvent() class LaunchNativePlayer(val playOptions: PlayOptions) : ActivityEvent() - class OpenUrl(val uri: String) : ActivityEvent() - class DownloadFile(val uri: Uri, val title: String, val filename: String) : ActivityEvent() - class RemoveDownload(val download: LocalJellyfinMediaSource, val force: Boolean = false) : ActivityEvent() + class OpenUrl(val uri: String, val grantReadPermission: Boolean = false) : ActivityEvent() + class DownloadItems(val itemIds: Collection) : ActivityEvent() class CastMessage(val action: String, val args: JSONArray) : ActivityEvent() data object RequestBluetoothPermission : ActivityEvent() data object OpenSettings : ActivityEvent() diff --git a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt index cc2514d1..f26ee625 100644 --- a/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt +++ b/app/src/main/java/org/jellyfin/mobile/events/ActivityEventHandler.kt @@ -20,7 +20,6 @@ import org.jellyfin.mobile.player.ui.PlayerFullscreenHelper import org.jellyfin.mobile.settings.SettingsFragment import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.extensions.addFragment -import org.jellyfin.mobile.utils.removeDownload import org.jellyfin.mobile.utils.requestDownload import org.jellyfin.mobile.webapp.WebappFunctionChannel import timber.log.Timber @@ -69,19 +68,15 @@ class ActivityEventHandler( is ActivityEvent.OpenUrl -> { try { val intent = Intent(Intent.ACTION_VIEW, event.uri.toUri()) + if (event.grantReadPermission) intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) startActivity(intent) } catch (e: ActivityNotFoundException) { Timber.e("openIntent: %s", e.message) } } - is ActivityEvent.DownloadFile -> { + is ActivityEvent.DownloadItems -> { lifecycleScope.launch { - with(event) { requestDownload(uri, filename) } - } - } - is ActivityEvent.RemoveDownload -> { - lifecycleScope.launch { - with(event) { removeDownload(download, force) } + with(event) { requestDownload(itemIds) } } } ActivityEvent.OpenDownloads -> { diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt index b19186be..35ddd1cb 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt @@ -8,7 +8,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Bitmap -import android.graphics.BitmapFactory import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.lifecycle.viewModelScope @@ -23,7 +22,6 @@ import org.jellyfin.mobile.BuildConfig import org.jellyfin.mobile.MainActivity import org.jellyfin.mobile.R import org.jellyfin.mobile.app.AppPreferences -import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.player.PlayerViewModel import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource @@ -39,7 +37,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject -import java.io.File import java.util.concurrent.atomic.AtomicBoolean class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinComponent { @@ -48,7 +45,6 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom private val notificationManager: NotificationManager? by lazy { context.getSystemService() } private val imageApi: ImageApi = get().imageApi private val imageLoader: ImageLoader by inject() - private val downloadDao: DownloadDao by inject() private val receiverRegistered = AtomicBoolean(false) val allowBackgroundAudio: Boolean @@ -155,17 +151,7 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom } private suspend fun loadImage(mediaSource: JellyfinMediaSource) = when (mediaSource) { - is LocalJellyfinMediaSource -> { - val downloadFolder = File( - downloadDao - .get(mediaSource.id) - .let(::requireNotNull) - .asMediaSource() - .localDirectoryUri, - ) - val thumbnailFile = File(downloadFolder, Constants.DOWNLOAD_THUMBNAIL_FILENAME) - BitmapFactory.decodeFile(thumbnailFile.canonicalPath) - } + is LocalJellyfinMediaSource -> null is RemoteJellyfinMediaSource -> { val height = context.resources.getDimensionPixelSize(R.dimen.media_notification_height) diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt index cb89ec9e..4c752f69 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt @@ -11,6 +11,9 @@ import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jellyfin.mobile.app.StorageManager import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.player.PlayerException import org.jellyfin.mobile.player.PlayerViewModel @@ -20,6 +23,7 @@ import org.jellyfin.mobile.player.source.ExternalSubtitleStream import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource import org.jellyfin.mobile.player.source.MediaSourceResolver +import org.jellyfin.mobile.player.source.PlaybackDetails import org.jellyfin.mobile.player.source.RemoteJellyfinMediaSource import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.videosApi @@ -44,6 +48,8 @@ class QueueManager( private val videosApi: VideosApi = apiClient.videosApi private val mediaSourceResolver: MediaSourceResolver by inject() private val deviceProfileBuilder: DeviceProfileBuilder by inject() + private val storageManager: StorageManager by inject() + private val downloadDao: DownloadDao by inject() private val deviceProfile = deviceProfileBuilder.getDeviceProfile() private var currentQueue: List = emptyList() @@ -73,7 +79,7 @@ class QueueManager( when (playOptions.playFromDownloads) { true -> playOptions.mediaSourceId?.let { startDownloadPlayback( - mediaSourceId = it, + itemId = itemId, playWhenReady = true, ) } @@ -92,21 +98,38 @@ class QueueManager( } private suspend fun startDownloadPlayback( - mediaSourceId: String, + itemId: UUID, startTime: Duration? = null, audioStreamIndex: Int? = null, subtitleStreamIndex: Int? = null, playWhenReady: Boolean = true, ): PlayerException? { - get() - .get(mediaSourceId) - ?.asMediaSource(startTime, audioStreamIndex, subtitleStreamIndex) - ?.also { jellyfinMediaSource -> - _currentMediaSource.value = jellyfinMediaSource + val download = withContext(Dispatchers.IO) { + downloadDao.getDownloadByItemId(itemId) + } ?: return PlayerException.UnsupportedContent() + + val storageLocation = storageManager.getStorageLocation() + + val filename = download.item.path?.replace(Regex("^.*[\\\\/]"), "") ?: error("Missing item path") + val fileLocation = storageLocation.findFile(download.path)?.findFile(filename)?.uri ?: return PlayerException.NetworkFailure() + + val mediaSource = LocalJellyfinMediaSource( + itemId = download.itemId, + item = download.item, + sourceInfo = download.item.mediaSources!!.first(), + playSessionId = download.id.toString(), + playbackDetails = PlaybackDetails(startTime, audioStreamIndex, subtitleStreamIndex), + remoteFileUri = fileLocation.toString(), + ) + startTime?.let { duration -> mediaSource.startTime = duration } + audioStreamIndex?.let { index -> mediaSource.selectAudioStream(mediaSource.audioStreams[index]) } + subtitleStreamIndex?.let { index -> mediaSource.selectSubtitleStream(mediaSource.subtitleStreams[index]) } + + _currentMediaSource.value = mediaSource + + // Load new media source + viewModel.load(mediaSource, prepareStreams(mediaSource), playWhenReady) - // Load new media source - viewModel.load(jellyfinMediaSource, prepareStreams(jellyfinMediaSource), playWhenReady) - } return null } @@ -208,7 +231,7 @@ class QueueManager( when (val currentMediaSource = getCurrentMediaSourceOrNull()) { is LocalJellyfinMediaSource -> startDownloadPlayback( - mediaSourceId = currentMediaSource.id, + itemId = currentQueue[++currentQueueIndex], playWhenReady = true, ) is RemoteJellyfinMediaSource -> startRemotePlayback( @@ -343,6 +366,7 @@ class QueueManager( val mediaItem = MediaItem.Builder() .setMediaId(mediaSourceId) .setUri(fileUri.toUri()) + .setCustomCacheKey(fileUri) .build() return mediaSourceFactory.createMediaSource(mediaItem) @@ -378,7 +402,7 @@ class QueueManager( when (val currentMediaSource = getCurrentMediaSourceOrNull()) { is LocalJellyfinMediaSource -> startDownloadPlayback( - mediaSourceId = currentMediaSource.id, + itemId = currentMediaSource.itemId, startTime = currentPlayState.position, audioStreamIndex = stream.index, subtitleStreamIndex = currentMediaSource.selectedSubtitleStreamIndex, @@ -411,7 +435,7 @@ class QueueManager( when (val mediaSource = getCurrentMediaSourceOrNull()) { is LocalJellyfinMediaSource -> startDownloadPlayback( - mediaSourceId = mediaSource.id, + itemId = mediaSource.itemId, startTime = currentPlayState.position, audioStreamIndex = mediaSource.selectedAudioStreamIndex, subtitleStreamIndex = stream?.index ?: -1, // -1 disables subtitles, null would select the default subtitle diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt b/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt deleted file mode 100644 index b5d44559..00000000 --- a/app/src/main/java/org/jellyfin/mobile/player/source/JellyfinMediaSourceSerializer.kt +++ /dev/null @@ -1,78 +0,0 @@ -package org.jellyfin.mobile.player.source - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerializationException -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.descriptors.buildClassSerialDescriptor -import kotlinx.serialization.descriptors.element -import kotlinx.serialization.encoding.CompositeDecoder -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.encoding.decodeStructure -import kotlinx.serialization.encoding.encodeStructure -import org.jellyfin.sdk.model.api.BaseItemDto -import org.jellyfin.sdk.model.api.MediaSourceInfo -import org.jellyfin.sdk.model.serializer.toUUID -import java.util.UUID - -@OptIn(ExperimentalSerializationApi::class) -class JellyfinMediaSourceSerializer : KSerializer { - override val descriptor: SerialDescriptor = buildClassSerialDescriptor("JellyfinMediaSource") { - element("itemId") - element("item", isOptional = true) - element("sourceInfo") - element("playSessionId") - element("downloadFolderUri") - element("downloadedFileUri") - element("downloadSize") - } - - @SuppressWarnings("MagicNumber") - override fun serialize(encoder: Encoder, value: LocalJellyfinMediaSource): Unit = - encoder.encodeStructure(descriptor) { - encodeStringElement(descriptor, 0, value.itemId.toString()) - encodeNullableSerializableElement(descriptor, 1, BaseItemDto.serializer(), value.item) - encodeSerializableElement(descriptor, 2, MediaSourceInfo.serializer(), value.sourceInfo) - encodeStringElement(descriptor, 3, value.playSessionId) - encodeStringElement(descriptor, 4, value.localDirectoryUri) - encodeStringElement(descriptor, 5, value.remoteFileUri) - encodeLongElement(descriptor, 6, value.downloadSize) - } - - @SuppressWarnings("MagicNumber") - override fun deserialize(decoder: Decoder): LocalJellyfinMediaSource = - decoder.decodeStructure(descriptor) { - var itemId: UUID? = null - var item: BaseItemDto? = null - var sourceInfo: MediaSourceInfo? = null - var playSessionId: String? = null - var downloadFolderUri: String? = null - var downloadedFileUri: String? = null - var downloadSize: Long? = null - - while (true) { - when (val index = decodeElementIndex(descriptor)) { - 0 -> itemId = decodeStringElement(descriptor, 0).toUUID() - 1 -> item = decodeNullableSerializableElement(descriptor, 1, BaseItemDto.serializer()) - 2 -> sourceInfo = decodeSerializableElement(descriptor, 2, MediaSourceInfo.serializer()) - 3 -> playSessionId = decodeStringElement(descriptor, 3) - 4 -> downloadFolderUri = decodeStringElement(descriptor, 4) - 5 -> downloadedFileUri = decodeStringElement(descriptor, 5) - 6 -> downloadSize = decodeLongElement(descriptor, 6) - CompositeDecoder.DECODE_DONE -> break - else -> throw SerializationException("Unknown index $index") - } - } - - LocalJellyfinMediaSource( - itemId = requireNotNull(itemId) { "Media source has no id" }, - item = item, - sourceInfo = requireNotNull(sourceInfo) { "Media source has no source info" }, - playSessionId = requireNotNull(playSessionId) { "Media source has no play session id" }, - localDirectoryUri = requireNotNull(downloadFolderUri) { "Media source has no download folder uri" }, - remoteFileUri = requireNotNull(downloadedFileUri) { "Media source has no downloaded file uri" }, - downloadSize = requireNotNull(downloadSize) { "Media source has no download size" }, - ) - } -} diff --git a/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt b/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt index 5a0dfef4..eb0ecea6 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/source/LocalJellyfinMediaSource.kt @@ -1,32 +1,17 @@ package org.jellyfin.mobile.player.source -import kotlinx.serialization.Serializable import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.PlayMethod import java.util.UUID -@Serializable(with = JellyfinMediaSourceSerializer::class) class LocalJellyfinMediaSource( itemId: UUID, item: BaseItemDto?, sourceInfo: MediaSourceInfo, playSessionId: String, playbackDetails: PlaybackDetails? = null, - val localDirectoryUri: String, val remoteFileUri: String, - val downloadSize: Long, ) : JellyfinMediaSource(itemId, item, sourceInfo, playSessionId, playbackDetails) { override val playMethod: PlayMethod = PlayMethod.DIRECT_PLAY - - constructor(source: JellyfinMediaSource, downloadFolder: String, downloadUrl: String, downloadSize: Long) : this( - source.itemId, - source.item, - source.sourceInfo, - source.playSessionId, - PlaybackDetails(source.startTime, source.selectedAudioStreamIndex, source.selectedSubtitleStreamIndex), - downloadFolder, - downloadUrl, - downloadSize, - ) } diff --git a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt index 5432e5ec..dae914fc 100644 --- a/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/settings/SettingsFragment.kt @@ -2,12 +2,12 @@ package org.jellyfin.mobile.settings import android.content.Intent import android.os.Bundle -import android.os.Environment import android.provider.Settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE +import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import de.Maxr1998.modernpreferences.Preference import de.Maxr1998.modernpreferences.PreferencesAdapter @@ -16,6 +16,7 @@ import de.Maxr1998.modernpreferences.helpers.checkBox import de.Maxr1998.modernpreferences.helpers.defaultOnCheckedChange import de.Maxr1998.modernpreferences.helpers.defaultOnClick import de.Maxr1998.modernpreferences.helpers.defaultOnSelectionChange +import de.Maxr1998.modernpreferences.helpers.onClick import de.Maxr1998.modernpreferences.helpers.pref import de.Maxr1998.modernpreferences.helpers.screen import de.Maxr1998.modernpreferences.helpers.singleChoice @@ -23,13 +24,13 @@ import de.Maxr1998.modernpreferences.preferences.CheckBoxPreference import de.Maxr1998.modernpreferences.preferences.choice.SelectionItem import org.jellyfin.mobile.R import org.jellyfin.mobile.app.AppPreferences +import org.jellyfin.mobile.app.StorageManager import org.jellyfin.mobile.databinding.FragmentSettingsBinding import org.jellyfin.mobile.downloads.DownloadMethod import org.jellyfin.mobile.utils.BackPressInterceptor import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.applyWindowInsetsAsMargins import org.jellyfin.mobile.utils.extensions.requireMainActivity -import org.jellyfin.mobile.utils.getDownloadsPaths import org.jellyfin.mobile.utils.isPackageInstalled import org.jellyfin.mobile.utils.withThemedContext import org.koin.android.ext.android.inject @@ -37,6 +38,20 @@ import org.koin.android.ext.android.inject class SettingsFragment : Fragment(), BackPressInterceptor { private val appPreferences: AppPreferences by inject() + private val storageManager: StorageManager by inject() + + private val storageLocationPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + if (uri != null) { + storageManager.changeStorageLocation(uri) + + // Update preference + if (::downloadLocationPreference.isInitialized) { + downloadLocationPreference.summary = storageManager.getStorageLocation().name + downloadLocationPreference.requestRebindAndHighlight() + } + } + } + private val settingsAdapter: PreferencesAdapter by lazy { PreferencesAdapter(buildSettingsScreen()) } private lateinit var startLandscapeVideoInLandscapePreference: CheckBoxPreference private lateinit var swipeGesturesPreference: CheckBoxPreference @@ -46,6 +61,7 @@ class SettingsFragment : Fragment(), BackPressInterceptor { private lateinit var directPlayAssPreference: Preference private lateinit var networkBufferPreference: Preference private lateinit var externalPlayerChoicePreference: Preference + private lateinit var downloadLocationPreference: Preference init { Preference.Config.titleMaxLines = 2 @@ -234,17 +250,17 @@ class SettingsFragment : Fragment(), BackPressInterceptor { val downloadMethods = listOf( SelectionItem( - DownloadMethod.WIFI_ONLY, + DownloadMethod.WIFI_ONLY.intValue, R.string.wifi_only, R.string.wifi_only_summary, ), SelectionItem( - DownloadMethod.MOBILE_DATA, + DownloadMethod.MOBILE_DATA.intValue, R.string.mobile_data, R.string.mobile_data_summary, ), SelectionItem( - DownloadMethod.MOBILE_AND_ROAMING, + DownloadMethod.MOBILE_AND_ROAMING.intValue, R.string.mobile_data_and_roaming, R.string.mobile_data_and_roaming_summary, ), @@ -253,20 +269,16 @@ class SettingsFragment : Fragment(), BackPressInterceptor { titleRes = R.string.network_title } - val downloadsDirs = requireContext().getDownloadsPaths().map { path -> - SelectionItem(path, path, null) - } - singleChoice(Constants.PREF_DOWNLOAD_LOCATION, downloadsDirs) { - titleRes = R.string.pref_download_location - initialSelection = Environment - .getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - .absolutePath - } + downloadLocationPreference = pref(Constants.PREF_STORAGE_LOCATION) { + val location = storageManager.getStorageLocation() - checkBox(Constants.PREF_DOWNLOAD_INTERNAL) { - titleRes = R.string.store_videos_in_internal_storage - summaryRes = R.string.stored_videos_in_internal_storage_desc - defaultValue = true + titleRes = R.string.pref_download_location + summary = location.name + + onClick { + storageLocationPicker.launch(location.uri) + false + } } } diff --git a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt index d20a9802..7e6af204 100644 --- a/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt +++ b/app/src/main/java/org/jellyfin/mobile/ui/screens/downloads/DownloadsList.kt @@ -1,28 +1,51 @@ package org.jellyfin.mobile.ui.screens.downloads +import android.content.Context +import android.text.format.Formatter import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.AlertDialog +import androidx.compose.material.Checkbox import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material.ListItem import androidx.compose.material.Text +import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.jellyfin.mobile.R +import org.jellyfin.mobile.app.StorageManager import org.jellyfin.mobile.data.entity.DownloadEntity +import org.jellyfin.mobile.downloads.DownloadStatus import org.jellyfin.mobile.downloads.DownloadsViewModel +import org.jellyfin.mobile.utils.lengthRecursive import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ImageType import org.koin.compose.koinInject @@ -32,19 +55,63 @@ fun DownloadsList( contentPadding: PaddingValues = PaddingValues.Zero, ) { val downloads by viewModel.downloads.collectAsState() + var downloadToRemove by remember { mutableStateOf(null) } + + if (downloadToRemove != null) { + val context = LocalContext.current + var keepLocalFiles by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { downloadToRemove = null }, + title = { Text(text = stringResource(R.string.download_remove)) }, + text = { + Column { + val name = remember(downloadToRemove, context) { + downloadToRemove?.item?.getDownloadName(context).orEmpty() + } + Text(text = stringResource(R.string.download_remove_description, name)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(top = 8.dp), + ) { + Checkbox( + checked = keepLocalFiles, + onCheckedChange = { keepLocalFiles = it }, + ) + Text(text = stringResource(R.string.download_remove_keep_local)) + } + } + }, + confirmButton = { + TextButton( + onClick = { + downloadToRemove?.let { viewModel.removeDownload(it, deleteFiles = !keepLocalFiles) } + downloadToRemove = null + }, + ) { + Text(text = stringResource(R.string.yes)) + } + }, + dismissButton = { + TextButton(onClick = { downloadToRemove = null }) { + Text(text = stringResource(R.string.download_cancel)) + } + }, + ) + } + LazyColumn( contentPadding = contentPadding, ) { items( downloads, - key = DownloadEntity::itemId, + key = DownloadEntity::id, ) { download -> DownloadItem( download, - modifier = Modifier.combinedClickable( - onClick = { viewModel.playDownload(download) }, - onLongClick = { viewModel.removeDownload(download) }, - ), + onOpen = { viewModel.openDownload(download) }, + onDownload = { viewModel.download(download) }, + onRemove = { downloadToRemove = download }, ) } } @@ -54,17 +121,36 @@ fun DownloadsList( @Composable fun DownloadItem( download: DownloadEntity, + onOpen: () -> Unit, + onDownload: () -> Unit, + onRemove: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val apiClient: ApiClient = koinInject() + val storageManager: StorageManager = koinInject() + + val fileSize by produceState(initialValue = 0L, download) { + value = withContext(Dispatchers.IO) { + val itemLocation = storageManager.getStorageLocation().findFile(download.path) + itemLocation?.lengthRecursive() + } + } ListItem( - modifier = modifier, + modifier = modifier + .combinedClickable( + onClick = { + if (fileSize == null) { + onDownload() + } else { + onOpen() + } + }, + onLongClick = { onRemove() }, + ), text = { - val name = remember(download.mediaSource.itemId) { - download.mediaSource.getName(context) - } + val name = remember(download.item, context) { download.item.getDownloadName(context) } Text( text = name, overflow = TextOverflow.Ellipsis, @@ -73,9 +159,9 @@ fun DownloadItem( }, icon = { val maxSize = LocalResources.current.getDimensionPixelSize(R.dimen.movie_thumbnail_list_size) - val url = remember(download.mediaSource.itemId) { + val url = remember(apiClient, download.itemId, maxSize) { apiClient.imageApi.getItemImageUrl( - itemId = download.mediaSource.itemId, + itemId = download.itemId, imageType = ImageType.PRIMARY, maxWidth = maxSize, maxHeight = maxSize, @@ -90,12 +176,52 @@ fun DownloadItem( ) }, secondaryText = { - Text( - text = download.fileSize, - overflow = TextOverflow.Ellipsis, - maxLines = 1, - ) + if (download.status == DownloadStatus.DOWNLOADING || download.status == DownloadStatus.QUEUED) { + LinearProgressIndicator() + } else if (fileSize != null) { + Text( + text = Formatter.formatShortFileSize(context, fileSize!!), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } else { + Text( + text = stringResource(R.string.download_incomplete), + color = Color.Yellow, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } }, singleLineSecondaryText = true, ) } + +private fun BaseItemDto.getDownloadName(context: Context) = buildString { + val name = if ( + type in arrayOf(BaseItemKind.PROGRAM, BaseItemKind.RECORDING) && + (isSeries == true || !episodeTitle.isNullOrEmpty()) + ) { + episodeTitle + } else { + name + } + + val extraInfo = when (type) { + BaseItemKind.TV_CHANNEL if !channelNumber.isNullOrEmpty() -> channelNumber + BaseItemKind.EPISODE if parentIndexNumber == 0 -> context.getString(R.string.special_episode) + in arrayOf(BaseItemKind.EPISODE, BaseItemKind.RECORDING) if indexNumber != null && parentIndexNumber != null -> + "S$parentIndexNumber:E${indexNumber}${indexNumberEnd?.let { n -> "-$n" } ?: ""}" + else -> "" + } + + listOf(seriesName, extraInfo, name) + .filter { str -> !str.isNullOrEmpty() } + .joinTo(this, separator = " - ") + + if (type == BaseItemKind.MOVIE && productionYear != null) { + append(" ($productionYear)") + } else if (premiereDate != null) { + append(" (${premiereDate!!.year})") + } +}.ifEmpty { name.orEmpty() } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt index 3f39fd58..47eec221 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt @@ -45,8 +45,7 @@ object Constants { const val NETWORK_BUFFER_EXTRA_LARGE = "extra_large" const val PREF_EXTERNAL_PLAYER_APP = "pref_external_player_app" const val PREF_SUBTITLE_STYLE = "pref_subtitle_style" - const val PREF_DOWNLOAD_LOCATION = "pref_download_location" - const val PREF_DOWNLOAD_INTERNAL = "pref_download_internal" + const val PREF_STORAGE_LOCATION = "pref_storage_location" const val PREF_MEDIA_SEGMENT_ACTIONS = "pref_media_segment_actions" // InputManager commands @@ -158,5 +157,4 @@ object Constants { // Misc const val PERCENT_MAX = 100 const val DOWNLOAD_PATH = "/MediaCache/" - const val DOWNLOAD_THUMBNAIL_FILENAME = "thumbnail.jpg" } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/DocumentFileExtensions.kt b/app/src/main/java/org/jellyfin/mobile/utils/DocumentFileExtensions.kt new file mode 100644 index 00000000..47f033ed --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/utils/DocumentFileExtensions.kt @@ -0,0 +1,13 @@ +package org.jellyfin.mobile.utils + +import androidx.documentfile.provider.DocumentFile + +fun DocumentFile.lengthRecursive(): Long? { + if (!exists()) return null + + return if (isDirectory) { + listFiles().sumOf { it.lengthRecursive() ?: 0L } + } else { + length() + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt index c11a483e..a4610883 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt @@ -3,7 +3,6 @@ package org.jellyfin.mobile.utils import android.Manifest import android.app.Activity import android.app.ActivityManager -import android.app.AlertDialog import android.app.NotificationChannel import android.app.NotificationManager import android.content.ActivityNotFoundException @@ -11,31 +10,26 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri -import android.os.Environment import android.os.PowerManager import android.provider.Settings import android.provider.Settings.System.ACCELEROMETER_ROTATION +import androidx.appcompat.app.AlertDialog import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.getSystemService -import androidx.media3.exoplayer.offline.DownloadService import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.suspendCancellableCoroutine import org.jellyfin.mobile.BuildConfig import org.jellyfin.mobile.MainActivity import org.jellyfin.mobile.R import org.jellyfin.mobile.app.AppPreferences -import org.jellyfin.mobile.data.dao.DownloadDao -import org.jellyfin.mobile.data.entity.DownloadEntity +import org.jellyfin.mobile.downloads.DownloadManager import org.jellyfin.mobile.downloads.DownloadMethod -import org.jellyfin.mobile.downloads.DownloadUtils -import org.jellyfin.mobile.downloads.JellyfinDownloadService -import org.jellyfin.mobile.player.source.LocalJellyfinMediaSource import org.jellyfin.mobile.settings.ExternalPlayerPackage import org.jellyfin.mobile.webapp.WebViewFragment import org.jellyfin.sdk.model.serializer.toUUID import org.koin.android.ext.android.get import timber.log.Timber -import java.io.File +import java.util.UUID import kotlin.coroutines.resume fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) { @@ -63,34 +57,11 @@ fun WebViewFragment.requestNoBatteryOptimizations(rootView: CoordinatorLayout) { } } -suspend fun MainActivity.requestDownload(uri: Uri, filename: String) { - val appPreferences: AppPreferences = get() +suspend fun MainActivity.requestDownload(itemIds: Collection) { + if (itemIds.isEmpty()) return - val downloadMethod = appPreferences.downloadMethod ?: suspendCancellableCoroutine { continuation -> - AlertDialog.Builder(this) - .setTitle(R.string.network_title) - .setMessage(R.string.network_message) - .setPositiveButton(R.string.wifi_only) { _, _ -> - val selectedDownloadMethod = DownloadMethod.WIFI_ONLY - appPreferences.downloadMethod = selectedDownloadMethod - continuation.resume(selectedDownloadMethod) - } - .setNegativeButton(R.string.mobile_data) { _, _ -> - val selectedDownloadMethod = DownloadMethod.MOBILE_DATA - appPreferences.downloadMethod = selectedDownloadMethod - continuation.resume(selectedDownloadMethod) - } - .setNeutralButton(R.string.mobile_data_and_roaming) { _, _ -> - val selectedDownloadMethod = DownloadMethod.MOBILE_AND_ROAMING - appPreferences.downloadMethod = selectedDownloadMethod - continuation.resume(selectedDownloadMethod) - } - .setOnDismissListener { - continuation.cancel(null) - } - .setCancelable(false) - .show() - } + val appPreferences: AppPreferences = get() + val downloadManager: DownloadManager = get() val permissionResult: Boolean = suspendCancellableCoroutine { continuation -> requestPermission("android.permission.POST_NOTIFICATIONS") { permissionsMap -> @@ -102,22 +73,26 @@ suspend fun MainActivity.requestDownload(uri: Uri, filename: String) { } } - if (permissionResult) { - val downloadUtils = DownloadUtils(this, filename, uri.toString(), downloadMethod) - downloadUtils.download() - } -} -suspend fun MainActivity.removeDownload(download: LocalJellyfinMediaSource, force: Boolean = false) { - if (!force) { - val confirmation = suspendCancellableCoroutine { continuation -> + // First time download, ask for network constraint preference + if (appPreferences.downloadMethod == null) { + suspendCancellableCoroutine { continuation -> AlertDialog.Builder(this) - .setTitle(getString(R.string.confirm_deletion)) - .setMessage(getString(R.string.confirm_deletion_desc, download.getName(this))) - .setPositiveButton(getString(R.string.yes)) { _, _ -> - continuation.resume(true) + .setTitle(R.string.network_title) + .setMessage(R.string.network_message) + .setPositiveButton(R.string.wifi_only) { _, _ -> + val selectedDownloadMethod = DownloadMethod.WIFI_ONLY + appPreferences.downloadMethod = selectedDownloadMethod + continuation.resume(selectedDownloadMethod) } - .setNegativeButton(getString(R.string.no)) { _, _ -> - continuation.cancel(null) + .setNegativeButton(R.string.mobile_data) { _, _ -> + val selectedDownloadMethod = DownloadMethod.MOBILE_DATA + appPreferences.downloadMethod = selectedDownloadMethod + continuation.resume(selectedDownloadMethod) + } + .setNeutralButton(R.string.mobile_data_and_roaming) { _, _ -> + val selectedDownloadMethod = DownloadMethod.MOBILE_AND_ROAMING + appPreferences.downloadMethod = selectedDownloadMethod + continuation.resume(selectedDownloadMethod) } .setOnDismissListener { continuation.cancel(null) @@ -125,33 +100,12 @@ suspend fun MainActivity.removeDownload(download: LocalJellyfinMediaSource, forc .setCancelable(false) .show() } - - if (!confirmation) return } - val downloadDao: DownloadDao = get() - val downloadEntity: DownloadEntity = requireNotNull(downloadDao.get(download.id)) - val downloadDir = File(downloadEntity.mediaSource.localDirectoryUri) - downloadDao.delete(download.id) - downloadDir.deleteRecursively() - - val contentId = download.itemId.toString() - // Remove media file - DownloadService.sendRemoveDownload( - this, - JellyfinDownloadService::class.java, - contentId, - false, - ) - - // Remove subtitles - download.externalSubtitleStreams.forEach { - DownloadService.sendRemoveDownload( - this, - JellyfinDownloadService::class.java, - "$contentId:${it.index}", - false, - ) + if (permissionResult) { + val server = mainViewModel.serverState.value.server ?: return + val user = mainViewModel.userState.value.user ?: return + downloadManager.enqueueItems(server, user, itemIds) } } @@ -176,25 +130,6 @@ fun Context.createMediaNotificationChannel(notificationManager: NotificationMana } } -fun Context.getDownloadsPaths(): List = ArrayList().apply { - for (directory in getExternalFilesDirs(null)) { - // Ignore currently unavailable shared storage - if (directory == null) continue - - val path = directory.absolutePath - val androidFolderIndex = path.indexOf("/Android") - if (androidFolderIndex == -1) continue - - val storageDirectory = File(path.substring(0, androidFolderIndex)) - if (storageDirectory.isDirectory) { - add(File(storageDirectory, Environment.DIRECTORY_DOWNLOADS).absolutePath) - } - } - if (isEmpty()) { - add(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath) - } -} - val Context.isLowRamDevice: Boolean get() = getSystemService()!!.isLowRamDevice diff --git a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt b/app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt deleted file mode 100644 index 77c5252c..00000000 --- a/app/src/main/java/org/jellyfin/mobile/utils/extensions/Long.kt +++ /dev/null @@ -1,21 +0,0 @@ -@file:Suppress("NOTHING_TO_INLINE") - -package org.jellyfin.mobile.utils.extensions - -import androidx.annotation.CheckResult -import org.jellyfin.mobile.data.entity.DownloadEntity.Key.BYTES_PER_BINARY_UNIT -import java.util.Locale - -@CheckResult -inline fun Long.toFileSize(): String { - val units = arrayOf("B", "KB", "MB", "GB", "TB") - var size = this.toDouble() - var unitIndex = 0 - - while (size >= BYTES_PER_BINARY_UNIT && unitIndex < units.lastIndex) { - size /= BYTES_PER_BINARY_UNIT - unitIndex++ - } - - return "%.1f %s".format(Locale.ROOT, size, units[unitIndex]) -} diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt index 8ef5a513..d0c69643 100644 --- a/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt +++ b/app/src/main/java/org/jellyfin/mobile/webapp/JellyfinWebViewClient.kt @@ -11,7 +11,7 @@ import androidx.webkit.WebViewClientCompat import androidx.webkit.WebViewFeature import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import org.jellyfin.mobile.app.ApiClientController +import org.jellyfin.mobile.MainViewModel import org.jellyfin.mobile.data.entity.ServerEntity import org.jellyfin.mobile.utils.Constants import org.jellyfin.mobile.utils.initLocale @@ -30,7 +30,7 @@ abstract class JellyfinWebViewClient( private val coroutineScope: CoroutineScope, private val server: ServerEntity, private val assetsPathHandler: AssetsPathHandler, - private val apiClientController: ApiClientController, + private val mainViewModel: MainViewModel, ) : WebViewClientCompat() { abstract fun onConnectedToWebapp() @@ -67,7 +67,7 @@ abstract class JellyfinWebViewClient( val storedServer = credentials.getJSONArray("Servers").getJSONObject(0) val user = storedServer.getString("UserId").toUUID() val token = storedServer.getString("AccessToken") - apiClientController.setupUser(server.id, user, token) + mainViewModel.setupUser(server.id, user, token) webView.initLocale(user) } null diff --git a/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt index 02115f4c..1340c259 100644 --- a/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt +++ b/app/src/main/java/org/jellyfin/mobile/webapp/WebViewFragment.kt @@ -23,8 +23,8 @@ import androidx.lifecycle.lifecycleScope import androidx.webkit.WebViewAssetLoader.AssetsPathHandler import androidx.webkit.WebViewCompat import kotlinx.coroutines.launch +import org.jellyfin.mobile.MainViewModel import org.jellyfin.mobile.R -import org.jellyfin.mobile.app.ApiClientController import org.jellyfin.mobile.app.AppPreferences import org.jellyfin.mobile.bridge.ExternalPlayer import org.jellyfin.mobile.bridge.MediaSegments @@ -47,10 +47,11 @@ import org.jellyfin.mobile.utils.isOutdated import org.jellyfin.mobile.utils.requestNoBatteryOptimizations import org.jellyfin.mobile.utils.runOnUiThread import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.activityViewModel class WebViewFragment : Fragment(), BackPressInterceptor, JellyfinWebChromeClient.FileChooserListener { val appPreferences: AppPreferences by inject() - private val apiClientController: ApiClientController by inject() + private val mainViewModel: MainViewModel by activityViewModel() private val webappFunctionChannel: WebappFunctionChannel by inject() private lateinit var assetsPathHandler: AssetsPathHandler private lateinit var jellyfinWebViewClient: JellyfinWebViewClient @@ -90,7 +91,7 @@ class WebViewFragment : Fragment(), BackPressInterceptor, JellyfinWebChromeClien lifecycleScope, server, assetsPathHandler, - apiClientController, + mainViewModel, ) { override fun onConnectedToWebapp() { val webViewBinding = webViewBinding ?: return diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 33c9643e..4dcac66b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,8 +142,6 @@ Failed to download thumbnail Failed to retrieve media information Please check your allowed download methods - Confirm deletion - Do you want to delete %1$s? Yes No Thumbnail @@ -153,4 +151,12 @@ Downloading %1$d titles Downloaded %1$s Special + Downloading %1$s + Cancel + Download completed + %1$d%% + Remove download + Keep local file(s) + Do you want to remove "%1$s" from your downloads? + Download incomplete diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fbd49c39..d2fb20dd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,7 +17,9 @@ androidx-core = "1.17.0" androidx-core-splashscreen = "1.2.0" androidx-appcompat = "1.7.1" androidx-activity = "1.11.0" +androidx-documentfile = "1.1.0" androidx-fragment = "1.8.9" +androidx-work = "2.10.5" androiddesugarlibs = "2.1.5" # Lifecycle extensions @@ -84,7 +86,9 @@ androidx-core = { group = "androidx.core", name = "core", version.ref = "android androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidx-core-splashscreen" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidx-appcompat" } androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "androidx-activity" } +androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "androidx-documentfile" } androidx-fragment = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "androidx-fragment" } +androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } androiddesugarlibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androiddesugarlibs" } # Lifecycle Extensions