From e5f3d4b20b253cef630c108c2a40e60b4cb2a770 Mon Sep 17 00:00:00 2001 From: LagradOst <46196380+Blatzar@users.noreply.github.com> Date: Sun, 7 Nov 2021 16:34:52 +0100 Subject: [PATCH] Added custom download directory (#219) Co-authored-by: Osten --- app/build.gradle | 9 +- .../ui/download/DownloadButtonSetup.kt | 1 + .../ui/download/DownloadFragment.kt | 7 +- .../ui/download/DownloadViewModel.kt | 2 +- .../cloudstream3/ui/player/PlayerFragment.kt | 32 +- .../ui/settings/SettingsFragment.kt | 110 +++++- .../utils/DownloadFileWorkManager.kt | 5 +- .../utils/VideoDownloadManager.kt | 331 +++++++++++++----- app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/settings.xml | 5 + 10 files changed, 390 insertions(+), 116 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f236b425..a8cb5578 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -86,14 +86,14 @@ dependencies { testImplementation 'org.json:json:20180813' implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'androidx.core:core-ktx:1.6.0' + implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.appcompat:appcompat:1.3.1' implementation 'com.google.android.material:material:1.4.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.1' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' - implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' testImplementation 'junit:junit:4.13.2' @@ -148,4 +148,7 @@ dependencies { // Networking implementation "com.squareup.okhttp3:okhttp:4.9.1" implementation "com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.1" + + // Util to skip the URI file fuckery 🙏 + implementation "com.github.tachiyomiorg:unifile:17bec43" } \ No newline at end of file diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt index 2432c855..0669b8d2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt @@ -98,6 +98,7 @@ object DownloadButtonSetup { R.id.global_to_navigation_player, PlayerFragment.newInstance( UriData( info.path.toString(), + keyInfo.basePath, keyInfo.relativePath, keyInfo.displayName, click.data.parentId, diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt index c3c6e14c..f0911bf3 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt @@ -17,6 +17,7 @@ import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.utils.AppUtils.loadResult +import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar @@ -44,8 +45,10 @@ class DownloadFragment : Fragment() { } private fun setList(list: List) { - (download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list - download_list?.adapter?.notifyDataSetChanged() + main { + (download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list + download_list?.adapter?.notifyDataSetChanged() + } } override fun onDestroyView() { diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt index d7f2bf67..a75b1795 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt @@ -113,4 +113,4 @@ class DownloadViewModel : ViewModel() { _headerCards.postValue(visual) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerFragment.kt index 108ad85f..dea065c7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerFragment.kt @@ -78,7 +78,6 @@ import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAu import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getCurrentSavedStyle import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.AppUtils.getFocusRequest -import com.lagradost.cloudstream3.utils.AppUtils.getVideoContentUri import com.lagradost.cloudstream3.utils.AppUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppUtils.onAudioFocusEvent import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus @@ -108,6 +107,7 @@ import kotlin.math.abs import kotlin.math.ceil import kotlin.properties.Delegates + //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 const val STATE_RESUME_WINDOW = "resumeWindow" const val STATE_RESUME_POSITION = "resumePosition" @@ -158,6 +158,7 @@ data class PlayerData( data class UriData( val uri: String, + val basePath: String?, val relativePath: String, val displayName: String, val parentId: Int?, @@ -791,12 +792,12 @@ class PlayerFragment : Fragment() { if (exoPlayer.duration > 0 && exoPlayer.currentPosition > 0) { context?.let { ctx -> //if (this::viewModel.isInitialized) { - viewModel.setViewPos( - ctx, - if (isDownloadedFile) uriData.id else getEpisode()?.id, - exoPlayer.currentPosition, - exoPlayer.duration - ) + viewModel.setViewPos( + ctx, + if (isDownloadedFile) uriData.id else getEpisode()?.id, + exoPlayer.currentPosition, + exoPlayer.duration + ) /*} else { ctx.setViewPos( if (isDownloadedFile) uriData.id else getEpisode()?.id, @@ -1608,7 +1609,7 @@ class PlayerFragment : Fragment() { if (isDownloadedFile) { if (!supportsDownloadedFiles) return null val list = ArrayList() - VideoDownloadManager.getFolder(this, uriData.relativePath)?.forEach { file -> + VideoDownloadManager.getFolder(this, uriData.relativePath, uriData.basePath)?.forEach { file -> val name = uriData.displayName.removeSuffix(".mp4") if (file.first != uriData.displayName && file.first.startsWith(name)) { val realName = file.first.removePrefix(name) @@ -1882,15 +1883,7 @@ class PlayerFragment : Fragment() { mediaItemBuilder.setUri(currentUrl.url) } else if (trueUri != null || uri != null) { val uriPrimary = trueUri ?: Uri.parse(uri) - if (uriPrimary.scheme == "content") { - mediaItemBuilder.setUri(uriPrimary) - // video_title?.text = uriPrimary.toString() - } else { - //mediaItemBuilder.setUri(Uri.parse(currentUrl.url)) - val realUri = trueUri ?: getVideoContentUri(requireContext(), uri ?: uriPrimary.path ?: "") - // video_title?.text = uri.toString() - mediaItemBuilder.setUri(realUri) - } + mediaItemBuilder.setUri(uriPrimary) } val subs = context?.getSubs() ?: emptyList() @@ -1913,7 +1906,7 @@ class PlayerFragment : Fragment() { activeSubtitles = subItemsId mediaItemBuilder.setSubtitles(subItems) -//might add https://github.com/ed828a/Aihua/blob/1896f46888b5a954b367e83f40b845ce174a2328/app/src/main/java/com/dew/aihua/player/playerUI/VideoPlayer.kt#L287 toggle caps + //might add https://github.com/ed828a/Aihua/blob/1896f46888b5a954b367e83f40b845ce174a2328/app/src/main/java/com/dew/aihua/player/playerUI/VideoPlayer.kt#L287 toggle caps val mediaItem = mediaItemBuilder.build() val trackSelector = DefaultTrackSelector(requireContext()) @@ -2214,7 +2207,8 @@ class PlayerFragment : Fragment() { @SuppressLint("ClickableViewAccessibility") private fun initPlayer() { if (isDownloadedFile) { - initPlayer(null, uriData.uri.removePrefix("file://").replace("%20", " ")) // FIX FILE PERMISSION + //.removePrefix("file://").replace("%20", " ") // FIX FILE PERMISSION + initPlayer(null, uriData.uri) } println("INIT PLAYER") view?.setOnTouchListener { _, _ -> return@setOnTouchListener true } // VERY IMPORTANT https://stackoverflow.com/questions/28818926/prevent-clicking-on-a-button-in-an-activity-while-showing-a-fragment diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt index 34d21f00..e5f7550b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt @@ -1,19 +1,26 @@ package com.lagradost.cloudstream3.ui.settings + +import android.content.Intent +import android.net.Uri import android.app.UiModeManager import android.content.Context import android.content.res.Configuration import android.os.Bundle +import android.os.Environment import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AlertDialog import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceManager +import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.getApiSettings import com.lagradost.cloudstream3.APIHolder.restrictedApis +import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.MainActivity.Companion.setLocale import com.lagradost.cloudstream3.MainActivity.Companion.showToast @@ -32,6 +39,10 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath +import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadDir +import com.lagradost.cloudstream3.utils.VideoDownloadManager.isScopedStorage +import java.io.File import kotlin.concurrent.thread @@ -54,6 +65,33 @@ class SettingsFragment : PreferenceFragmentCompat() { private var beneneCount = 0 + // Open file picker + private val pathPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> + // It lies, it can be null if file manager quits. + if (uri == null) return@registerForActivityResult + val context = context ?: AcraApplication.context ?: return@registerForActivityResult + // RW perms for the path + val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + + context.contentResolver.takePersistableUriPermission(uri, flags) + + val file = UniFile.fromUri(context, uri) + println("Selected URI path: $uri - Full path: ${file.filePath}") + + // Stores the real URI using download_path_key + // Important that the URI is stored instead of filepath due to permissions. + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(getString(R.string.download_path_key), uri.toString()).apply() + + // From URI -> File path + // File path here is purely for cosmetic purposes in settings + (file.filePath ?: uri.toString()).let { + PreferenceManager.getDefaultSharedPreferences(context) + .edit().putString(getString(R.string.download_path_pref), it).apply() + } + } + // idk, if you find a way of automating this it would be great private val languages = arrayListOf( Triple("\uD83C\uDDEA\uD83C\uDDF8", "Spanish", "es"), @@ -84,6 +122,7 @@ class SettingsFragment : PreferenceFragmentCompat() { val legalPreference = findPreference(getString(R.string.legal_notice_key))!! val subdubPreference = findPreference(getString(R.string.display_sub_key))!! val providerLangPreference = findPreference(getString(R.string.provider_lang_key))!! + val downloadPathPreference = findPreference(getString(R.string.download_path_key))!! val allLayoutPreference = findPreference(getString(R.string.app_layout_key))!! val colorPrimaryPreference = findPreference(getString(R.string.primary_color_key))!! val preferedMediaTypePreference = findPreference(getString(R.string.prefer_media_type_key))!! @@ -167,7 +206,49 @@ class SettingsFragment : PreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + + fun getDownloadDirs(): List { + val defaultDir = getDownloadDir()?.filePath + // app_name_download_path = Cloudstream and does not change depending on release. + // DOES NOT WORK ON SCOPED STORAGE. + val secondaryDir = if (isScopedStorage) null else Environment.getExternalStorageDirectory().absolutePath + + File.separator + resources.getString(R.string.app_name_download_path) + + val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second } + + return (listOf(defaultDir, secondaryDir) + + requireContext().getExternalFilesDirs("").mapNotNull { it.path } + + currentDir).filterNotNull().distinct() + } + + downloadPathPreference.setOnPreferenceClickListener { + val dirs = getDownloadDirs() + val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) + + val currentDir = + settingsManager.getString(getString(R.string.download_path_pref), null) ?: getDownloadDir().toString() + + context?.showBottomDialog( + dirs + listOf("Custom"), + dirs.indexOf(currentDir), + getString(R.string.download_path_pref), + true, + {}) { + // Last = custom + if (it == dirs.size) { + pathPicker.launch(Uri.EMPTY) + } else { + // Sets both visual and actual paths. + // key = used path + // pref = visual path + settingsManager.edit().putString(getString(R.string.download_path_key), dirs[it]).apply() + settingsManager.edit().putString(getString(R.string.download_path_pref), dirs[it]).apply() + } + } + return@setOnPreferenceClickListener true + } + preferedMediaTypePreference.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.media_type_pref) val prefValues = resources.getIntArray(R.array.media_type_pref_values) @@ -206,9 +287,10 @@ class SettingsFragment : PreferenceFragmentCompat() { true, {}) { try { - settingsManager.edit().putInt(getString(R.string.app_layout_key), prefValues[it]).apply() + settingsManager.edit().putInt(getString(R.string.app_layout_key), prefValues[it]) + .apply() activity?.recreate() - } catch (e : Exception) { + } catch (e: Exception) { logError(e) } } @@ -220,7 +302,8 @@ class SettingsFragment : PreferenceFragmentCompat() { val prefValues = resources.getStringArray(R.array.themes_overlay_names_values) val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val currentLayout = settingsManager.getString( getString(R.string.primary_color_key),prefValues.first()) + val currentLayout = + settingsManager.getString(getString(R.string.primary_color_key), prefValues.first()) context?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentLayout), @@ -228,9 +311,10 @@ class SettingsFragment : PreferenceFragmentCompat() { true, {}) { try { - settingsManager.edit().putString(getString(R.string.primary_color_key), prefValues[it]).apply() + settingsManager.edit().putString(getString(R.string.primary_color_key), prefValues[it]) + .apply() activity?.recreate() - } catch (e : Exception) { + } catch (e: Exception) { logError(e) } } @@ -242,7 +326,8 @@ class SettingsFragment : PreferenceFragmentCompat() { val prefValues = resources.getStringArray(R.array.themes_names_values) val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) - val currentLayout = settingsManager.getString( getString(R.string.app_theme_key),prefValues.first()) + val currentLayout = + settingsManager.getString(getString(R.string.app_theme_key), prefValues.first()) context?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentLayout), @@ -250,9 +335,10 @@ class SettingsFragment : PreferenceFragmentCompat() { true, {}) { try { - settingsManager.edit().putString(getString(R.string.app_theme_key), prefValues[it]).apply() + settingsManager.edit().putString(getString(R.string.app_theme_key), prefValues[it]) + .apply() activity?.recreate() - } catch (e : Exception) { + } catch (e: Exception) { logError(e) } } @@ -265,14 +351,18 @@ class SettingsFragment : PreferenceFragmentCompat() { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val currentQuality = - settingsManager.getInt(getString(R.string.watch_quality_pref), Qualities.values().last().value) + settingsManager.getInt( + getString(R.string.watch_quality_pref), + Qualities.values().last().value + ) context?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref), true, {}) { - settingsManager.edit().putInt(getString(R.string.watch_quality_pref), prefValues[it]).apply() + settingsManager.edit().putInt(getString(R.string.watch_quality_pref), prefValues[it]) + .apply() } return@setOnPreferenceClickListener true } diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt index 50c008f4..16936f49 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt @@ -5,6 +5,7 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey @@ -33,7 +34,6 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo val info = applicationContext.getKey(WORK_KEY_INFO, key) val pkg = applicationContext.getKey(WORK_KEY_PACKAGE, key) - if (info != null) { downloadEpisode( applicationContext, @@ -44,6 +44,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo ::handleNotification ) awaitDownload(info.ep.id) + } else if (pkg != null) { downloadFromResume(applicationContext, pkg, ::handleNotification) awaitDownload(pkg.item.ep.id) @@ -52,6 +53,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo } return Result.success() } catch (e: Exception) { + logError(e) if (key != null) { removeKeys(key) } @@ -79,6 +81,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo } downloadStatusEvent += listener while (!isDone) { + println("AWAITING $id") delay(1000) } downloadStatusEvent -= listener diff --git a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt index 4be709ac..ac6b6b31 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadManager.kt @@ -15,11 +15,13 @@ import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.net.toUri +import androidx.preference.PreferenceManager import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import com.fasterxml.jackson.annotation.JsonProperty +import com.hippo.unifile.UniFile import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError @@ -33,7 +35,11 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -import java.io.* +import okhttp3.internal.closeQuietly +import java.io.BufferedInputStream +import java.io.File +import java.io.InputStream +import java.io.OutputStream import java.lang.Thread.sleep import java.net.URI import java.net.URL @@ -126,7 +132,8 @@ object VideoDownloadManager { val totalBytes: Long, val relativePath: String, val displayName: String, - val extraInfo: String? = null + val extraInfo: String? = null, + val basePath: String? = null // null is for legacy downloads. See getDefaultPath() ) data class DownloadedFileInfoResult( @@ -210,6 +217,7 @@ object VideoDownloadManager { } return null } catch (e: Exception) { + logError(e) return null } } @@ -256,9 +264,9 @@ object VideoDownloadManager { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } val pendingIntent: PendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) } else { - PendingIntent.getActivity(context, 0, intent, 0) + PendingIntent.getActivity(context, 0, intent, 0) } builder.setContentIntent(pendingIntent) } @@ -433,25 +441,34 @@ object VideoDownloadManager { } return list } catch (e: Exception) { + logError(e) return null } } - fun getFolder(context: Context, relativePath: String): List>? { - if (isScopedStorage()) { + /** + * Used for getting video player subs. + * @return List of pairs for the files in this format: + * */ + fun getFolder(context: Context, relativePath: String, basePath: String?): List>? { + val base = basePathToFile(context, basePath) + val folder = base?.gotoDir(relativePath, false) + + if (isScopedStorage && base.isDownloadDir()) { return context.contentResolver?.getExistingFolderStartName(relativePath) } else { - val normalPath = - "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( - '/', - File.separatorChar - ) - val folder = File(normalPath) - if (folder.isDirectory) { - return folder.listFiles()?.map { Pair(it.name, it.toUri()) } +// val normalPath = +// "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( +// '/', +// File.separatorChar +// ) +// val folder = File(normalPath) + if (folder?.isDirectory == true) { + return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) } } - return null } + return null +// } } @RequiresApi(Build.VERSION_CODES.Q) @@ -487,6 +504,7 @@ object VideoDownloadManager { } return null } catch (e: Exception) { + logError(e) return null } } @@ -497,13 +515,14 @@ object VideoDownloadManager { this.openFileDescriptor(fileUri, "r") .use { it?.statSize ?: 0 } } catch (e: Exception) { + logError(e) null } } - private fun isScopedStorage(): Boolean { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - } + val isScopedStorage: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + data class CreateNotificationMetadata( val type: DownloadType, @@ -518,6 +537,10 @@ object VideoDownloadManager { val fileStream: OutputStream? = null, ) + /** + * Sets up the appropriate file and creates a data stream from the file. + * Used for initializing downloads. + * */ private fun setupStream( context: Context, name: String, @@ -525,16 +548,17 @@ object VideoDownloadManager { extension: String, tryResume: Boolean, ): StreamData { - val relativePath = getRelativePath(folder) val displayName = getDisplayName(name, extension) val fileStream: OutputStream val fileLength: Long var resume = tryResume - if (isScopedStorage()) { + val baseFile = context.getBasePath() + + if (isScopedStorage && baseFile.first?.isDownloadDir() == true) { val cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) val currentExistingFile = - cr.getExistingDownloadUriOrNullQ(relativePath, displayName) // CURRENT FILE WITH THE SAME PATH + cr.getExistingDownloadUriOrNullQ(folder ?: "", displayName) // CURRENT FILE WITH THE SAME PATH fileLength = if (currentExistingFile == null || !resume) 0 else (cr.getFileLength(currentExistingFile) @@ -566,7 +590,7 @@ object VideoDownloadManager { put(MediaStore.MediaColumns.TITLE, name) if (currentMimeType != null) put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.RELATIVE_PATH, folder) } cr.insert( @@ -578,26 +602,24 @@ object VideoDownloadManager { fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else "")) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) } else { - val normalPath = getNormalPath(relativePath, displayName) - // NORMAL NON SCOPED STORAGE FILE CREATION - val rFile = File(normalPath) - if (!rFile.exists()) { + val subDir = baseFile.first?.gotoDir(folder) + val rFile = subDir?.findFile(displayName) + if (rFile?.exists() != true) { fileLength = 0 - rFile.parentFile?.mkdirs() - if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) + if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) } else { if (resume) { - fileLength = rFile.length() + fileLength = rFile.size() } else { fileLength = 0 - rFile.parentFile?.mkdirs() - if (!rFile.delete()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) - if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) + if (!rFile.delete()) return StreamData(ERROR_DELETING_FILE) + if (subDir.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE) } } - fileStream = FileOutputStream(rFile, false) + fileStream = (subDir.findFile(displayName) ?: subDir.createFile(displayName))!!.openOutputStream() +// fileStream = FileOutputStream(rFile, false) + if (fileLength == 0L) resume = false } - if (fileLength == 0L) resume = false return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream) } @@ -615,15 +637,16 @@ object VideoDownloadManager { return ERROR_UNKNOWN } - val relativePath = getRelativePath(folder) - val displayName = getDisplayName(name, extension) + val basePath = context.getBasePath() + val displayName = getDisplayName(name, extension) + val relativePath = if (isScopedStorage && basePath.first.isDownloadDir()) getRelativePath(folder) else folder fun deleteFile(): Int { - return delete(context, name, folder, extension, parentId) + return delete(context, name, relativePath, extension, parentId, basePath.first) } - val stream = setupStream(context, name, folder, extension, tryResume) + val stream = setupStream(context, name, relativePath, extension, tryResume) if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode val resume = stream.resume!! @@ -681,7 +704,12 @@ object VideoDownloadManager { context.setKey( KEY_DOWNLOAD_INFO, it.toString(), - DownloadedFileInfo(bytesTotal, relativePath, displayName) + DownloadedFileInfo( + bytesTotal, + relativePath ?: "", + displayName, + basePath = basePath.second + ) ) } @@ -789,6 +817,7 @@ object VideoDownloadManager { fileStream.write(buffer, 0, count) } } catch (e: Exception) { + logError(e) isFailed = true updateNotification() } @@ -832,16 +861,118 @@ object VideoDownloadManager { } } - private fun getRelativePath(folder: String?): String { - return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace('/', File.separatorChar) + + /** + * Guarantees a directory is present with the dir name (if createMissingDirectories is true). + * Works recursively when '/' is present. + * Will remove any file with the dir name if present and add directory. + * Will not work if the parent directory does not exist. + * + * @param directoryName if null will use the current path. + * @return UniFile / null if createMissingDirectories = false and folder is not found. + * */ + private fun UniFile.gotoDir(directoryName: String?, createMissingDirectories: Boolean = true): UniFile? { + + // May give this error on scoped storage. + // W/DocumentsContract: Failed to create document + // java.lang.IllegalArgumentException: Parent document isn't a directory + + // Not present in latest testing. + +// println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}") + + try { + // Creates itself from parent if doesn't exist. + if (!this.exists() && createMissingDirectories && !this.name.isNullOrBlank()) { + if (this.parentFile != null) { + this.parentFile?.createDirectory(this.name) + } else if (this.filePath != null) { + UniFile.fromFile(File(this.filePath!!).parentFile)?.createDirectory(this.name) + } + } + + val allDirectories = directoryName?.split("/") + return if (allDirectories?.size == 1 || allDirectories == null) { + val found = this.findFile(directoryName) + when { + directoryName.isNullOrBlank() -> this + found?.isDirectory == true -> found + + !createMissingDirectories -> null + // Below creates directories + found?.isFile == true -> { + found.delete() + this.createDirectory(directoryName) + } + this.isDirectory -> this.createDirectory(directoryName) + else -> this.parentFile?.createDirectory(directoryName) + } + } else { + var currentDirectory = this + allDirectories.forEach { + // If the next directory is not found it returns the deepest directory possible. + val nextDir = currentDirectory.gotoDir(it, createMissingDirectories) + currentDirectory = nextDir ?: return null + } + currentDirectory + } + } catch (e: Exception) { + logError(e) + return null + } } private fun getDisplayName(name: String, extension: String): String { return "$name.$extension" } - private fun getNormalPath(relativePath: String, displayName: String): String { - return "${Environment.getExternalStorageDirectory()}${File.separatorChar}$relativePath$displayName" + /** + * Gets the default download path as an UniFile. + * Vital for legacy downloads, be careful about changing anything here. + * + * As of writing UniFile is used for everything but download directory on scoped storage. + * Special ContentResolver fuckery is needed for that as UniFile doesn't work. + * */ + fun getDownloadDir(): UniFile? { + // See https://www.py4u.net/discuss/614761 + return UniFile.fromFile( + File( + Environment.getExternalStorageDirectory().absolutePath + File.separatorChar + + Environment.DIRECTORY_DOWNLOADS + ) + ) + } + + @Deprecated("TODO fix UniFile to work with download directory.") + private fun getRelativePath(folder: String?): String { + return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace('/', File.separatorChar) + } + + /** + * Turns a string to an UniFile. Used for stored string paths such as settings. + * Should only be used to get a download path. + * */ + private fun basePathToFile(context: Context, path: String?): UniFile? { + return when { + path.isNullOrBlank() -> getDownloadDir() + path.startsWith("content://") -> UniFile.fromUri(context, path.toUri()) + else -> UniFile.fromFile(File(path)) + } + } + + /** + * Base path where downloaded things should be stored, changes depending on settings. + * Returns the file and a string to be stored for future file retrieval. + * UniFile.filePath is not sufficient for storage. + * */ + fun Context.getBasePath(): Pair { + val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) + val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) + return basePathToFile(this, basePathSetting) to basePathSetting + } + + private fun UniFile?.isDownloadDir(): Boolean { + return this != null && this.filePath == getDownloadDir()?.filePath } private fun delete( @@ -850,20 +981,29 @@ object VideoDownloadManager { folder: String?, extension: String, parentId: Int?, + basePath: UniFile? ): Int { - val relativePath = getRelativePath(folder) val displayName = getDisplayName(name, extension) - if (isScopedStorage()) { + // If scoped storage and using download dir (not accessible with UniFile) + if (isScopedStorage && basePath.isDownloadDir()) { + val relativePath = getRelativePath(folder) val lastContent = context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName) if (lastContent != null) { context.contentResolver.delete(lastContent, null, null) } } else { - if (!File(getNormalPath(relativePath, displayName)).delete()) return ERROR_DELETING_FILE - } - parentId?.let { - downloadDeleteEvent.invoke(parentId) + val dir = basePath?.gotoDir(folder) + val file = dir?.findFile(displayName) + val success = file?.delete() + if (success != true) return ERROR_DELETING_FILE else { + // Cleans up empty directory + if (dir.listFiles()?.isEmpty() == true) dir.delete() + } +// } + parentId?.let { + downloadDeleteEvent.invoke(parentId) + } } return SUCCESS_STOPPED } @@ -898,16 +1038,20 @@ object VideoDownloadManager { ) var realIndex = startIndex ?: 0 - val stream = setupStream(context, name, folder, extension, realIndex > 0) + val basePath = context.getBasePath() + + val relativePath = if (isScopedStorage && basePath.first.isDownloadDir()) getRelativePath(folder) else folder + + val stream = setupStream(context, name, relativePath, extension, realIndex > 0) if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode if (!stream.resume!!) realIndex = 0 val fileLengthAdd = stream.fileLength!! val tsIterator = m3u8Helper.hlsYield(listOf(m3u8), realIndex) - val relativePath = getRelativePath(folder) val displayName = getDisplayName(name, extension) + val fileStream = stream.fileStream!! val firstTs = tsIterator.next() @@ -920,7 +1064,7 @@ object VideoDownloadManager { val totalTs = firstTs.totalTs.toLong() fun deleteFile(): Int { - return delete(context, name, folder, extension, parentId) + return delete(context, name, relativePath, extension, parentId, basePath.first) } /* Most of the auto generated m3u8 out there have TS of the same size. @@ -939,13 +1083,15 @@ object VideoDownloadManager { it.toString(), DownloadedFileInfo( (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), - relativePath, + relativePath ?: "", displayName, - tsProgress.toString() + tsProgress.toString(), + basePath = basePath.second ) ) } } + updateInfo() fun updateNotification() { @@ -1153,7 +1299,6 @@ object VideoDownloadManager { ) } } - } ?: ERROR_UNKNOWN } @@ -1189,7 +1334,7 @@ object VideoDownloadManager { link, notificationCallback, resume - ) + ).also { println("Single episode finished with return code: $it") } } } if (connectionResult != null && connectionResult > 0) { // SUCCESS @@ -1217,8 +1362,9 @@ object VideoDownloadManager { private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null + val base = basePathToFile(context, info.basePath) - if (isScopedStorage()) { + if (isScopedStorage && base.isDownloadDir()) { val cr = context.contentResolver ?: return null val fileUri = cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) ?: return null @@ -1226,10 +1372,28 @@ object VideoDownloadManager { if (fileLength == 0L) return null return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri) } else { - val normalPath = getNormalPath(info.relativePath, info.displayName) - val dFile = File(normalPath) - if (!dFile.exists()) return null - return DownloadedFileInfoResult(dFile.length(), info.totalBytes, dFile.toUri()) + + val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) + +// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) +// val dFile = File(normalPath) + + if (file?.exists() != true) return null + + return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri) + } + } + + /** + * Gets the true download size as Scoped Storage sometimes wrongly returns 0. + * */ + fun UniFile.size(): Long { + val len = length() + return if (len <= 1) { + val inputStream = this.openInputStream() + return inputStream.available().toLong().also { inputStream.closeQuietly() } + } else { + len } } @@ -1245,8 +1409,8 @@ object VideoDownloadManager { downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped)) downloadDeleteEvent.invoke(id) - - if (isScopedStorage()) { + val base = basePathToFile(context, info.basePath) + if (isScopedStorage && base.isDownloadDir()) { val cr = context.contentResolver ?: return false val fileUri = cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) @@ -1254,10 +1418,17 @@ object VideoDownloadManager { return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0 } else { - val normalPath = getNormalPath(info.relativePath, info.displayName) - val dFile = File(normalPath) - if (!dFile.exists()) return true - return dFile.delete() + val file = base?.gotoDir(info.relativePath)?.findFile(info.displayName) +// val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) +// val dFile = File(normalPath) + if (file?.exists() != true) return true + return try { + file.delete() + } catch (e: Exception) { + logError(e) + val cr = context.contentResolver + cr.delete(file.uri, null, null) > 0 + } } } @@ -1272,19 +1443,19 @@ object VideoDownloadManager { setKey: Boolean = true ) { if (!currentDownloads.any { it == pkg.item.ep.id }) { - if (currentDownloads.size == maxConcurrentDownloads) { - main { -// showToast( // can be replaced with regular Toast -// context, -// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ -// context.getString( -// R.string.queued -// ) -// }", -// Toast.LENGTH_SHORT -// ) - } - } +// if (currentDownloads.size == maxConcurrentDownloads) { +// main { +//// showToast( // can be replaced with regular Toast +//// context, +//// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ +//// context.getString( +//// R.string.queued +//// ) +//// }", +//// Toast.LENGTH_SHORT +//// ) +// } +// } downloadQueue.addLast(pkg) downloadCheck(context, notificationCallback) if (setKey) saveQueue(context) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fad20de2..10fd2b62 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -24,11 +24,14 @@ show_fillers_key provider_lang_key dns_key + download_path_key + Cloudstream app_layout_key primary_color_key prefer_media_type_key app_theme_key + %d %s | %sMB %s • %sGB @@ -270,6 +273,7 @@ DNS over HTTPS Useful for bypassing ISP blocks + Download path Display Dubbed/Subbed Anime diff --git a/app/src/main/res/xml/settings.xml b/app/src/main/res/xml/settings.xml index acfcb644..f9f2b1f2 100644 --- a/app/src/main/res/xml/settings.xml +++ b/app/src/main/res/xml/settings.xml @@ -104,6 +104,11 @@ android:summary="@string/dns_pref_summary" android:icon="@drawable/ic_baseline_dns_24"> + +