Added custom download directory (#219)

Co-authored-by: Osten <balt.758@gmail.com>
This commit is contained in:
LagradOst 2021-11-07 16:34:52 +01:00 committed by GitHub
parent 13ba73bbd4
commit e5f3d4b20b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 390 additions and 116 deletions

View file

@ -86,14 +86,14 @@ dependencies {
testImplementation 'org.json:json:20180813' testImplementation 'org.json:json:20180813'
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 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 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0' implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.1' implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-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-livedata-ktx:2.4.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
@ -148,4 +148,7 @@ dependencies {
// Networking // Networking
implementation "com.squareup.okhttp3:okhttp:4.9.1" implementation "com.squareup.okhttp3:okhttp:4.9.1"
implementation "com.squareup.okhttp3:okhttp-dnsoverhttps: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"
} }

View file

@ -98,6 +98,7 @@ object DownloadButtonSetup {
R.id.global_to_navigation_player, PlayerFragment.newInstance( R.id.global_to_navigation_player, PlayerFragment.newInstance(
UriData( UriData(
info.path.toString(), info.path.toString(),
keyInfo.basePath,
keyInfo.relativePath, keyInfo.relativePath,
keyInfo.displayName, keyInfo.displayName,
click.data.parentId, click.data.parentId,

View file

@ -17,6 +17,7 @@ import com.lagradost.cloudstream3.isMovieType
import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observe
import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick
import com.lagradost.cloudstream3.utils.AppUtils.loadResult 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.DOWNLOAD_EPISODE_CACHE
import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.DataStore
import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbar
@ -44,9 +45,11 @@ class DownloadFragment : Fragment() {
} }
private fun setList(list: List<VisualDownloadHeaderCached>) { private fun setList(list: List<VisualDownloadHeaderCached>) {
main {
(download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list (download_list?.adapter as DownloadHeaderAdapter?)?.cardList = list
download_list?.adapter?.notifyDataSetChanged() download_list?.adapter?.notifyDataSetChanged()
} }
}
override fun onDestroyView() { override fun onDestroyView() {
(download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter() (download_list?.adapter as DownloadHeaderAdapter?)?.killAdapter()

View file

@ -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.ui.subtitles.SubtitlesFragment.Companion.getCurrentSavedStyle
import com.lagradost.cloudstream3.utils.* import com.lagradost.cloudstream3.utils.*
import com.lagradost.cloudstream3.utils.AppUtils.getFocusRequest 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.isCastApiAvailable
import com.lagradost.cloudstream3.utils.AppUtils.onAudioFocusEvent import com.lagradost.cloudstream3.utils.AppUtils.onAudioFocusEvent
import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus import com.lagradost.cloudstream3.utils.AppUtils.requestLocalAudioFocus
@ -108,6 +107,7 @@ import kotlin.math.abs
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.properties.Delegates import kotlin.properties.Delegates
//http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
const val STATE_RESUME_WINDOW = "resumeWindow" const val STATE_RESUME_WINDOW = "resumeWindow"
const val STATE_RESUME_POSITION = "resumePosition" const val STATE_RESUME_POSITION = "resumePosition"
@ -158,6 +158,7 @@ data class PlayerData(
data class UriData( data class UriData(
val uri: String, val uri: String,
val basePath: String?,
val relativePath: String, val relativePath: String,
val displayName: String, val displayName: String,
val parentId: Int?, val parentId: Int?,
@ -1608,7 +1609,7 @@ class PlayerFragment : Fragment() {
if (isDownloadedFile) { if (isDownloadedFile) {
if (!supportsDownloadedFiles) return null if (!supportsDownloadedFiles) return null
val list = ArrayList<SubtitleFile>() val list = ArrayList<SubtitleFile>()
VideoDownloadManager.getFolder(this, uriData.relativePath)?.forEach { file -> VideoDownloadManager.getFolder(this, uriData.relativePath, uriData.basePath)?.forEach { file ->
val name = uriData.displayName.removeSuffix(".mp4") val name = uriData.displayName.removeSuffix(".mp4")
if (file.first != uriData.displayName && file.first.startsWith(name)) { if (file.first != uriData.displayName && file.first.startsWith(name)) {
val realName = file.first.removePrefix(name) val realName = file.first.removePrefix(name)
@ -1882,15 +1883,7 @@ class PlayerFragment : Fragment() {
mediaItemBuilder.setUri(currentUrl.url) mediaItemBuilder.setUri(currentUrl.url)
} else if (trueUri != null || uri != null) { } else if (trueUri != null || uri != null) {
val uriPrimary = trueUri ?: Uri.parse(uri) val uriPrimary = trueUri ?: Uri.parse(uri)
if (uriPrimary.scheme == "content") {
mediaItemBuilder.setUri(uriPrimary) 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)
}
} }
val subs = context?.getSubs() ?: emptyList() val subs = context?.getSubs() ?: emptyList()
@ -2214,7 +2207,8 @@ class PlayerFragment : Fragment() {
@SuppressLint("ClickableViewAccessibility") @SuppressLint("ClickableViewAccessibility")
private fun initPlayer() { private fun initPlayer() {
if (isDownloadedFile) { 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") 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 view?.setOnTouchListener { _, _ -> return@setOnTouchListener true } // VERY IMPORTANT https://stackoverflow.com/questions/28818926/prevent-clicking-on-a-button-in-an-activity-while-showing-a-fragment

View file

@ -1,19 +1,26 @@
package com.lagradost.cloudstream3.ui.settings package com.lagradost.cloudstream3.ui.settings
import android.content.Intent
import android.net.Uri
import android.app.UiModeManager import android.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.apis
import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings import com.lagradost.cloudstream3.APIHolder.getApiDubstatusSettings
import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings import com.lagradost.cloudstream3.APIHolder.getApiProviderLangSettings
import com.lagradost.cloudstream3.APIHolder.getApiSettings import com.lagradost.cloudstream3.APIHolder.getApiSettings
import com.lagradost.cloudstream3.APIHolder.restrictedApis import com.lagradost.cloudstream3.APIHolder.restrictedApis
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.DubStatus
import com.lagradost.cloudstream3.MainActivity.Companion.setLocale import com.lagradost.cloudstream3.MainActivity.Companion.setLocale
import com.lagradost.cloudstream3.MainActivity.Companion.showToast 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.SingleSelectionHelper.showMultiDialog
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard 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 import kotlin.concurrent.thread
@ -54,6 +65,33 @@ class SettingsFragment : PreferenceFragmentCompat() {
private var beneneCount = 0 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 // idk, if you find a way of automating this it would be great
private val languages = arrayListOf( private val languages = arrayListOf(
Triple("\uD83C\uDDEA\uD83C\uDDF8", "Spanish", "es"), Triple("\uD83C\uDDEA\uD83C\uDDF8", "Spanish", "es"),
@ -84,6 +122,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
val legalPreference = findPreference<Preference>(getString(R.string.legal_notice_key))!! val legalPreference = findPreference<Preference>(getString(R.string.legal_notice_key))!!
val subdubPreference = findPreference<Preference>(getString(R.string.display_sub_key))!! val subdubPreference = findPreference<Preference>(getString(R.string.display_sub_key))!!
val providerLangPreference = findPreference<Preference>(getString(R.string.provider_lang_key))!! val providerLangPreference = findPreference<Preference>(getString(R.string.provider_lang_key))!!
val downloadPathPreference = findPreference<Preference>(getString(R.string.download_path_key))!!
val allLayoutPreference = findPreference<Preference>(getString(R.string.app_layout_key))!! val allLayoutPreference = findPreference<Preference>(getString(R.string.app_layout_key))!!
val colorPrimaryPreference = findPreference<Preference>(getString(R.string.primary_color_key))!! val colorPrimaryPreference = findPreference<Preference>(getString(R.string.primary_color_key))!!
val preferedMediaTypePreference = findPreference<Preference>(getString(R.string.prefer_media_type_key))!! val preferedMediaTypePreference = findPreference<Preference>(getString(R.string.prefer_media_type_key))!!
@ -168,6 +207,48 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@setOnPreferenceClickListener true return@setOnPreferenceClickListener true
} }
fun getDownloadDirs(): List<String> {
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 { preferedMediaTypePreference.setOnPreferenceClickListener {
val prefNames = resources.getStringArray(R.array.media_type_pref) val prefNames = resources.getStringArray(R.array.media_type_pref)
val prefValues = resources.getIntArray(R.array.media_type_pref_values) val prefValues = resources.getIntArray(R.array.media_type_pref_values)
@ -206,7 +287,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
true, true,
{}) { {}) {
try { 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() activity?.recreate()
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -220,7 +302,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
val prefValues = resources.getStringArray(R.array.themes_overlay_names_values) val prefValues = resources.getStringArray(R.array.themes_overlay_names_values)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) 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( context?.showBottomDialog(
prefNames.toList(), prefNames.toList(),
prefValues.indexOf(currentLayout), prefValues.indexOf(currentLayout),
@ -228,7 +311,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
true, true,
{}) { {}) {
try { 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() activity?.recreate()
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -242,7 +326,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
val prefValues = resources.getStringArray(R.array.themes_names_values) val prefValues = resources.getStringArray(R.array.themes_names_values)
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) 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( context?.showBottomDialog(
prefNames.toList(), prefNames.toList(),
prefValues.indexOf(currentLayout), prefValues.indexOf(currentLayout),
@ -250,7 +335,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
true, true,
{}) { {}) {
try { 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() activity?.recreate()
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
@ -265,14 +351,18 @@ class SettingsFragment : PreferenceFragmentCompat() {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val settingsManager = PreferenceManager.getDefaultSharedPreferences(context)
val currentQuality = 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( context?.showBottomDialog(
prefNames.toList(), prefNames.toList(),
prefValues.indexOf(currentQuality), prefValues.indexOf(currentQuality),
getString(R.string.watch_quality_pref), getString(R.string.watch_quality_pref),
true, 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 return@setOnPreferenceClickListener true
} }

View file

@ -5,6 +5,7 @@ import android.content.Context
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.DataStore.removeKey
@ -33,7 +34,6 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo
val info = applicationContext.getKey<VideoDownloadManager.DownloadInfo>(WORK_KEY_INFO, key) val info = applicationContext.getKey<VideoDownloadManager.DownloadInfo>(WORK_KEY_INFO, key)
val pkg = val pkg =
applicationContext.getKey<VideoDownloadManager.DownloadResumePackage>(WORK_KEY_PACKAGE, key) applicationContext.getKey<VideoDownloadManager.DownloadResumePackage>(WORK_KEY_PACKAGE, key)
if (info != null) { if (info != null) {
downloadEpisode( downloadEpisode(
applicationContext, applicationContext,
@ -44,6 +44,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo
::handleNotification ::handleNotification
) )
awaitDownload(info.ep.id) awaitDownload(info.ep.id)
} else if (pkg != null) { } else if (pkg != null) {
downloadFromResume(applicationContext, pkg, ::handleNotification) downloadFromResume(applicationContext, pkg, ::handleNotification)
awaitDownload(pkg.item.ep.id) awaitDownload(pkg.item.ep.id)
@ -52,6 +53,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo
} }
return Result.success() return Result.success()
} catch (e: Exception) { } catch (e: Exception) {
logError(e)
if (key != null) { if (key != null) {
removeKeys(key) removeKeys(key)
} }
@ -79,6 +81,7 @@ class DownloadFileWorkManager(val context: Context, private val workerParams: Wo
} }
downloadStatusEvent += listener downloadStatusEvent += listener
while (!isDone) { while (!isDone) {
println("AWAITING $id")
delay(1000) delay(1000)
} }
downloadStatusEvent -= listener downloadStatusEvent -= listener

View file

@ -15,11 +15,13 @@ import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.preference.PreferenceManager
import androidx.work.Data import androidx.work.Data
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
@ -33,7 +35,11 @@ import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext 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.lang.Thread.sleep
import java.net.URI import java.net.URI
import java.net.URL import java.net.URL
@ -126,7 +132,8 @@ object VideoDownloadManager {
val totalBytes: Long, val totalBytes: Long,
val relativePath: String, val relativePath: String,
val displayName: 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( data class DownloadedFileInfoResult(
@ -210,6 +217,7 @@ object VideoDownloadManager {
} }
return null return null
} catch (e: Exception) { } catch (e: Exception) {
logError(e)
return null return null
} }
} }
@ -433,25 +441,34 @@ object VideoDownloadManager {
} }
return list return list
} catch (e: Exception) { } catch (e: Exception) {
logError(e)
return null return null
} }
} }
fun getFolder(context: Context, relativePath: String): List<Pair<String, Uri>>? { /**
if (isScopedStorage()) { * Used for getting video player subs.
* @return List of pairs for the files in this format: <Name, Uri>
* */
fun getFolder(context: Context, relativePath: String, basePath: String?): List<Pair<String, Uri>>? {
val base = basePathToFile(context, basePath)
val folder = base?.gotoDir(relativePath, false)
if (isScopedStorage && base.isDownloadDir()) {
return context.contentResolver?.getExistingFolderStartName(relativePath) return context.contentResolver?.getExistingFolderStartName(relativePath)
} else { } else {
val normalPath = // val normalPath =
"${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace( // "${Environment.getExternalStorageDirectory()}${File.separatorChar}${relativePath}".replace(
'/', // '/',
File.separatorChar // File.separatorChar
) // )
val folder = File(normalPath) // val folder = File(normalPath)
if (folder.isDirectory) { if (folder?.isDirectory == true) {
return folder.listFiles()?.map { Pair(it.name, it.toUri()) } return folder.listFiles()?.map { Pair(it.name ?: "", it.uri) }
}
} }
return null return null
} // }
} }
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@ -487,6 +504,7 @@ object VideoDownloadManager {
} }
return null return null
} catch (e: Exception) { } catch (e: Exception) {
logError(e)
return null return null
} }
} }
@ -497,13 +515,14 @@ object VideoDownloadManager {
this.openFileDescriptor(fileUri, "r") this.openFileDescriptor(fileUri, "r")
.use { it?.statSize ?: 0 } .use { it?.statSize ?: 0 }
} catch (e: Exception) { } catch (e: Exception) {
logError(e)
null null
} }
} }
private fun isScopedStorage(): Boolean { val isScopedStorage: Boolean
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}
data class CreateNotificationMetadata( data class CreateNotificationMetadata(
val type: DownloadType, val type: DownloadType,
@ -518,6 +537,10 @@ object VideoDownloadManager {
val fileStream: OutputStream? = null, val fileStream: OutputStream? = null,
) )
/**
* Sets up the appropriate file and creates a data stream from the file.
* Used for initializing downloads.
* */
private fun setupStream( private fun setupStream(
context: Context, context: Context,
name: String, name: String,
@ -525,16 +548,17 @@ object VideoDownloadManager {
extension: String, extension: String,
tryResume: Boolean, tryResume: Boolean,
): StreamData { ): StreamData {
val relativePath = getRelativePath(folder)
val displayName = getDisplayName(name, extension) val displayName = getDisplayName(name, extension)
val fileStream: OutputStream val fileStream: OutputStream
val fileLength: Long val fileLength: Long
var resume = tryResume 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 cr = context.contentResolver ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
val currentExistingFile = val currentExistingFile =
cr.getExistingDownloadUriOrNullQ(relativePath, displayName) // CURRENT FILE WITH THE SAME PATH cr.getExistingDownloadUriOrNullQ(folder ?: "", displayName) // CURRENT FILE WITH THE SAME PATH
fileLength = fileLength =
if (currentExistingFile == null || !resume) 0 else (cr.getFileLength(currentExistingFile) if (currentExistingFile == null || !resume) 0 else (cr.getFileLength(currentExistingFile)
@ -566,7 +590,7 @@ object VideoDownloadManager {
put(MediaStore.MediaColumns.TITLE, name) put(MediaStore.MediaColumns.TITLE, name)
if (currentMimeType != null) if (currentMimeType != null)
put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType) put(MediaStore.MediaColumns.MIME_TYPE, currentMimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) put(MediaStore.MediaColumns.RELATIVE_PATH, folder)
} }
cr.insert( cr.insert(
@ -578,26 +602,24 @@ object VideoDownloadManager {
fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else "")) fileStream = cr.openOutputStream(newFileUri, "w" + (if (appendFile) "a" else ""))
?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) ?: return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
} else { } else {
val normalPath = getNormalPath(relativePath, displayName) val subDir = baseFile.first?.gotoDir(folder)
// NORMAL NON SCOPED STORAGE FILE CREATION val rFile = subDir?.findFile(displayName)
val rFile = File(normalPath) if (rFile?.exists() != true) {
if (!rFile.exists()) {
fileLength = 0 fileLength = 0
rFile.parentFile?.mkdirs() if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE)
if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
} else { } else {
if (resume) { if (resume) {
fileLength = rFile.length() fileLength = rFile.size()
} else { } else {
fileLength = 0 fileLength = 0
rFile.parentFile?.mkdirs() if (!rFile.delete()) return StreamData(ERROR_DELETING_FILE)
if (!rFile.delete()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) if (subDir.createFile(displayName) == null) return StreamData(ERROR_CREATE_FILE)
if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND)
} }
} }
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) return StreamData(SUCCESS_STREAM, resume, fileLength, fileStream)
} }
@ -615,15 +637,16 @@ object VideoDownloadManager {
return ERROR_UNKNOWN return ERROR_UNKNOWN
} }
val relativePath = getRelativePath(folder) val basePath = context.getBasePath()
val displayName = getDisplayName(name, extension)
val displayName = getDisplayName(name, extension)
val relativePath = if (isScopedStorage && basePath.first.isDownloadDir()) getRelativePath(folder) else folder
fun deleteFile(): Int { 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 if (stream.errorCode != SUCCESS_STREAM) return stream.errorCode
val resume = stream.resume!! val resume = stream.resume!!
@ -681,7 +704,12 @@ object VideoDownloadManager {
context.setKey( context.setKey(
KEY_DOWNLOAD_INFO, KEY_DOWNLOAD_INFO,
it.toString(), it.toString(),
DownloadedFileInfo(bytesTotal, relativePath, displayName) DownloadedFileInfo(
bytesTotal,
relativePath ?: "",
displayName,
basePath = basePath.second
)
) )
} }
@ -789,6 +817,7 @@ object VideoDownloadManager {
fileStream.write(buffer, 0, count) fileStream.write(buffer, 0, count)
} }
} catch (e: Exception) { } catch (e: Exception) {
logError(e)
isFailed = true isFailed = true
updateNotification() 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 { private fun getDisplayName(name: String, extension: String): String {
return "$name.$extension" 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<UniFile?, String?> {
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( private fun delete(
@ -850,21 +981,30 @@ object VideoDownloadManager {
folder: String?, folder: String?,
extension: String, extension: String,
parentId: Int?, parentId: Int?,
basePath: UniFile?
): Int { ): Int {
val relativePath = getRelativePath(folder)
val displayName = getDisplayName(name, extension) 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) val lastContent = context.contentResolver.getExistingDownloadUriOrNullQ(relativePath, displayName)
if (lastContent != null) { if (lastContent != null) {
context.contentResolver.delete(lastContent, null, null) context.contentResolver.delete(lastContent, null, null)
} }
} else { } else {
if (!File(getNormalPath(relativePath, displayName)).delete()) return ERROR_DELETING_FILE 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 { parentId?.let {
downloadDeleteEvent.invoke(parentId) downloadDeleteEvent.invoke(parentId)
} }
}
return SUCCESS_STOPPED return SUCCESS_STOPPED
} }
@ -898,16 +1038,20 @@ object VideoDownloadManager {
) )
var realIndex = startIndex ?: 0 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.errorCode != SUCCESS_STREAM) return stream.errorCode
if (!stream.resume!!) realIndex = 0 if (!stream.resume!!) realIndex = 0
val fileLengthAdd = stream.fileLength!! val fileLengthAdd = stream.fileLength!!
val tsIterator = m3u8Helper.hlsYield(listOf(m3u8), realIndex) val tsIterator = m3u8Helper.hlsYield(listOf(m3u8), realIndex)
val relativePath = getRelativePath(folder)
val displayName = getDisplayName(name, extension) val displayName = getDisplayName(name, extension)
val fileStream = stream.fileStream!! val fileStream = stream.fileStream!!
val firstTs = tsIterator.next() val firstTs = tsIterator.next()
@ -920,7 +1064,7 @@ object VideoDownloadManager {
val totalTs = firstTs.totalTs.toLong() val totalTs = firstTs.totalTs.toLong()
fun deleteFile(): Int { 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. Most of the auto generated m3u8 out there have TS of the same size.
@ -939,13 +1083,15 @@ object VideoDownloadManager {
it.toString(), it.toString(),
DownloadedFileInfo( DownloadedFileInfo(
(bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(),
relativePath, relativePath ?: "",
displayName, displayName,
tsProgress.toString() tsProgress.toString(),
basePath = basePath.second
) )
) )
} }
} }
updateInfo() updateInfo()
fun updateNotification() { fun updateNotification() {
@ -1153,7 +1299,6 @@ object VideoDownloadManager {
) )
} }
} }
} ?: ERROR_UNKNOWN } ?: ERROR_UNKNOWN
} }
@ -1189,7 +1334,7 @@ object VideoDownloadManager {
link, link,
notificationCallback, notificationCallback,
resume resume
) ).also { println("Single episode finished with return code: $it") }
} }
} }
if (connectionResult != null && connectionResult > 0) { // SUCCESS if (connectionResult != null && connectionResult > 0) { // SUCCESS
@ -1217,8 +1362,9 @@ object VideoDownloadManager {
private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? {
val info = context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return null val info = context.getKey<DownloadedFileInfo>(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 cr = context.contentResolver ?: return null
val fileUri = val fileUri =
cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) ?: return null cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) ?: return null
@ -1226,10 +1372,28 @@ object VideoDownloadManager {
if (fileLength == 0L) return null if (fileLength == 0L) return null
return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri) return DownloadedFileInfoResult(fileLength, info.totalBytes, fileUri)
} else { } else {
val normalPath = getNormalPath(info.relativePath, info.displayName)
val dFile = File(normalPath) val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName)
if (!dFile.exists()) return null
return DownloadedFileInfoResult(dFile.length(), info.totalBytes, dFile.toUri()) // 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)) downloadProgressEvent.invoke(Triple(id, 0, 0))
downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped)) downloadStatusEvent.invoke(Pair(id, DownloadType.IsStopped))
downloadDeleteEvent.invoke(id) downloadDeleteEvent.invoke(id)
val base = basePathToFile(context, info.basePath)
if (isScopedStorage()) { if (isScopedStorage && base.isDownloadDir()) {
val cr = context.contentResolver ?: return false val cr = context.contentResolver ?: return false
val fileUri = val fileUri =
cr.getExistingDownloadUriOrNullQ(info.relativePath, info.displayName) 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 return cr.delete(fileUri, null, null) > 0 // IF DELETED ROWS IS OVER 0
} else { } else {
val normalPath = getNormalPath(info.relativePath, info.displayName) val file = base?.gotoDir(info.relativePath)?.findFile(info.displayName)
val dFile = File(normalPath) // val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName)
if (!dFile.exists()) return true // val dFile = File(normalPath)
return dFile.delete() 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 setKey: Boolean = true
) { ) {
if (!currentDownloads.any { it == pkg.item.ep.id }) { if (!currentDownloads.any { it == pkg.item.ep.id }) {
if (currentDownloads.size == maxConcurrentDownloads) { // if (currentDownloads.size == maxConcurrentDownloads) {
main { // main {
// showToast( // can be replaced with regular Toast //// showToast( // can be replaced with regular Toast
// context, //// context,
// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${ //// "${pkg.item.ep.mainName}${pkg.item.ep.episode?.let { " ${context.getString(R.string.episode)} $it " } ?: " "}${
// context.getString( //// context.getString(
// R.string.queued //// R.string.queued
// ) //// )
// }", //// }",
// Toast.LENGTH_SHORT //// Toast.LENGTH_SHORT
// ) //// )
} // }
} // }
downloadQueue.addLast(pkg) downloadQueue.addLast(pkg)
downloadCheck(context, notificationCallback) downloadCheck(context, notificationCallback)
if (setKey) saveQueue(context) if (setKey) saveQueue(context)

View file

@ -24,11 +24,14 @@
<string name="show_fillers_key" translatable="false">show_fillers_key</string> <string name="show_fillers_key" translatable="false">show_fillers_key</string>
<string name="provider_lang_key" translatable="false">provider_lang_key</string> <string name="provider_lang_key" translatable="false">provider_lang_key</string>
<string name="dns_key" translatable="false">dns_key</string> <string name="dns_key" translatable="false">dns_key</string>
<string name="download_path_key" translatable="false">download_path_key</string>
<string name="app_name_download_path" translatable="false">Cloudstream</string>
<string name="app_layout_key" translatable="false">app_layout_key</string> <string name="app_layout_key" translatable="false">app_layout_key</string>
<string name="primary_color_key" translatable="false">primary_color_key</string> <string name="primary_color_key" translatable="false">primary_color_key</string>
<string name="prefer_media_type_key" translatable="false">prefer_media_type_key</string> <string name="prefer_media_type_key" translatable="false">prefer_media_type_key</string>
<string name="app_theme_key" translatable="false">app_theme_key</string> <string name="app_theme_key" translatable="false">app_theme_key</string>
<!-- FORMAT MIGHT TRANSLATE, WILL CAUSE CRASH IF APPLIED WRONG --> <!-- FORMAT MIGHT TRANSLATE, WILL CAUSE CRASH IF APPLIED WRONG -->
<string name="extra_info_format" translatable="false" formatted="true">%d %s | %sMB</string> <string name="extra_info_format" translatable="false" formatted="true">%d %s | %sMB</string>
<string name="storage_size_format" translatable="false" formatted="true">%s • %sGB</string> <string name="storage_size_format" translatable="false" formatted="true">%s • %sGB</string>
@ -270,6 +273,7 @@
<string name="dns_pref">DNS over HTTPS</string> <string name="dns_pref">DNS over HTTPS</string>
<string name="dns_pref_summary">Useful for bypassing ISP blocks</string> <string name="dns_pref_summary">Useful for bypassing ISP blocks</string>
<string name="download_path_pref">Download path</string>
<string name="display_subbed_dubbed_settings">Display Dubbed/Subbed Anime</string> <string name="display_subbed_dubbed_settings">Display Dubbed/Subbed Anime</string>

View file

@ -104,6 +104,11 @@
android:summary="@string/dns_pref_summary" android:summary="@string/dns_pref_summary"
android:icon="@drawable/ic_baseline_dns_24"> android:icon="@drawable/ic_baseline_dns_24">
</Preference> </Preference>
<Preference
android:key="@string/download_path_key"
android:title="@string/download_path_pref"
android:icon="@drawable/netflix_download">
</Preference>
<Preference <Preference
android:icon="@drawable/ic_baseline_tv_24" android:icon="@drawable/ic_baseline_tv_24"
android:key="@string/app_layout_key" android:key="@string/app_layout_key"