forked from recloudstream/cloudstream
		
	Unifile implementation
This commit is contained in:
		
							parent
							
								
									70c14d116c
								
							
						
					
					
						commit
						4b1e5ab9b1
					
				
					 5 changed files with 292 additions and 76 deletions
				
			
		|  | @ -85,14 +85,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' | ||||||
|  | @ -141,10 +141,13 @@ dependencies { | ||||||
|     //implementation 'com.github.TorrentStream:TorrentStream-Android:2.7.0' |     //implementation 'com.github.TorrentStream:TorrentStream-Android:2.7.0' | ||||||
| 
 | 
 | ||||||
|     // Downloading |     // Downloading | ||||||
|     implementation "androidx.work:work-runtime:2.7.0-rc01" |     implementation "androidx.work:work-runtime:2.7.0" | ||||||
|     implementation "androidx.work:work-runtime-ktx:2.7.0-rc01" |     implementation "androidx.work:work-runtime-ktx:2.7.0" | ||||||
| 
 | 
 | ||||||
|     // 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" | ||||||
| } | } | ||||||
|  | @ -1,16 +1,22 @@ | ||||||
| package com.lagradost.cloudstream3.ui.settings | package com.lagradost.cloudstream3.ui.settings | ||||||
| 
 | 
 | ||||||
|  | import android.content.Intent | ||||||
|  | import android.net.Uri | ||||||
| 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 | ||||||
|  | @ -26,12 +32,41 @@ 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 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class SettingsFragment : PreferenceFragmentCompat() { | class SettingsFragment : PreferenceFragmentCompat() { | ||||||
|     private var beneneCount = 0 |     private var beneneCount = 0 | ||||||
| 
 | 
 | ||||||
|  |     // Open file picker | ||||||
|  |     private val pathPicker = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> | ||||||
|  |         val 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"), | ||||||
|  | @ -61,6 +96,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))!! | ||||||
| 
 | 
 | ||||||
|         legalPreference.setOnPreferenceClickListener { |         legalPreference.setOnPreferenceClickListener { | ||||||
|             val builder: AlertDialog.Builder = AlertDialog.Builder(it.context) |             val builder: AlertDialog.Builder = AlertDialog.Builder(it.context) | ||||||
|  | @ -141,6 +177,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 | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|         watchQualityPreference.setOnPreferenceClickListener { |         watchQualityPreference.setOnPreferenceClickListener { | ||||||
|             val prefNames = resources.getStringArray(R.array.quality_pref) |             val prefNames = resources.getStringArray(R.array.quality_pref) | ||||||
|             val prefValues = resources.getIntArray(R.array.quality_pref_values) |             val prefValues = resources.getIntArray(R.array.quality_pref_values) | ||||||
|  |  | ||||||
|  | @ -15,11 +15,14 @@ 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.google.android.exoplayer2.util.UriUtil | ||||||
|  | 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 | ||||||
|  | @ -126,7 +129,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( | ||||||
|  | @ -437,21 +441,29 @@ object VideoDownloadManager { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * Used for getting video player subs. | ||||||
|  |      * @return List of pairs for the files in this format: <Name, Uri> | ||||||
|  |      * */ | ||||||
|     fun getFolder(context: Context, relativePath: String): List<Pair<String, Uri>>? { |     fun getFolder(context: Context, relativePath: String): List<Pair<String, Uri>>? { | ||||||
|         if (isScopedStorage()) { |         val base = context.getBasePath().first | ||||||
|  |         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) | ||||||
|  | @ -501,9 +513,9 @@ object VideoDownloadManager { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     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 +530,56 @@ object VideoDownloadManager { | ||||||
|         val fileStream: OutputStream? = null, |         val fileStream: OutputStream? = null, | ||||||
|     ) |     ) | ||||||
| 
 | 
 | ||||||
|  |     /** | ||||||
|  |      * 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. | ||||||
|  |      * | ||||||
|  |      * @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? { | ||||||
|  | 
 | ||||||
|  |         // Can give this error on scoped storage, haven't solved. | ||||||
|  |         // W/DocumentsContract: Failed to create document | ||||||
|  |         // java.lang.IllegalArgumentException: Parent document isn't a directory | ||||||
|  | 
 | ||||||
|  |         try { | ||||||
|  |             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 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /** | ||||||
|  |      * 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,12 +587,14 @@ 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 relativePath = getRelativePath(folder) | ||||||
|             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 = | ||||||
|  | @ -578,26 +642,25 @@ 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) |             println("SEYUP FFFFFFF") | ||||||
|             // NORMAL NON SCOPED STORAGE FILE CREATION |             val subDir = baseFile.first?.gotoDir(folder) | ||||||
|             val rFile = File(normalPath) |             val rFile = subDir?.findFile(displayName) | ||||||
|             if (!rFile.exists()) { |             if (rFile?.exists() != true) { | ||||||
|                 fileLength = 0 |                 fileLength = 0 | ||||||
|                 rFile.parentFile?.mkdirs() |                 if (subDir?.createFile(displayName) == null) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) | ||||||
|                 if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) |  | ||||||
|             } else { |             } else { | ||||||
|                 if (resume) { |                 if (resume) { | ||||||
|                     fileLength = rFile.length() |                     fileLength = rFile.length() | ||||||
|                 } else { |                 } else { | ||||||
|                     fileLength = 0 |                     fileLength = 0 | ||||||
|                     rFile.parentFile?.mkdirs() |  | ||||||
|                     if (!rFile.delete()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) |                     if (!rFile.delete()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) | ||||||
|                     if (!rFile.createNewFile()) return StreamData(ERROR_CONTENT_RESOLVER_NOT_FOUND) |                     if (subDir.createFile(displayName) == null) 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,12 +678,14 @@ object VideoDownloadManager { | ||||||
|             return ERROR_UNKNOWN |             return ERROR_UNKNOWN | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         val relativePath = getRelativePath(folder) |         val basePath = context.getBasePath() | ||||||
|  | 
 | ||||||
|  | //        val relativePath = getRelativePath(folder) | ||||||
|         val displayName = getDisplayName(name, extension) |         val displayName = getDisplayName(name, extension) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|         fun deleteFile(): Int { |         fun deleteFile(): Int { | ||||||
|             return delete(context, name, folder, extension, parentId) |             return delete(context, name, folder, extension, parentId, basePath.first) | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         val stream = setupStream(context, name, folder, extension, tryResume) |         val stream = setupStream(context, name, folder, extension, tryResume) | ||||||
|  | @ -681,7 +746,7 @@ object VideoDownloadManager { | ||||||
|             context.setKey( |             context.setKey( | ||||||
|                 KEY_DOWNLOAD_INFO, |                 KEY_DOWNLOAD_INFO, | ||||||
|                 it.toString(), |                 it.toString(), | ||||||
|                 DownloadedFileInfo(bytesTotal, relativePath, displayName) |                 DownloadedFileInfo(bytesTotal, folder ?: "", displayName, basePath = basePath.second) | ||||||
|             ) |             ) | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  | @ -832,16 +897,58 @@ object VideoDownloadManager { | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private fun getRelativePath(folder: String?): String { |  | ||||||
|         return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace('/', File.separatorChar) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     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 == null -> 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 { | ||||||
|  |         println("DOWNLOAD DIR $this ${this?.filePath} ${this?.uri}") | ||||||
|  |         return this != null && this.filePath == getDownloadDir()?.filePath | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     private fun delete( |     private fun delete( | ||||||
|  | @ -850,21 +957,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 success = dir?.findFile(displayName) | ||||||
|  |                 ?.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 | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -875,7 +991,7 @@ object VideoDownloadManager { | ||||||
|         folder: String?, |         folder: String?, | ||||||
|         parentId: Int?, |         parentId: Int?, | ||||||
|         startIndex: Int?, |         startIndex: Int?, | ||||||
|         createNotificationCallback: (CreateNotificationMetadata) -> Unit |         createNotificationCallback: (VideoDownloadManager.CreateNotificationMetadata) -> Unit | ||||||
|     ): Int { |     ): Int { | ||||||
|         val extension = "mp4" |         val extension = "mp4" | ||||||
|         fun logcatPrint(vararg items: Any?) { |         fun logcatPrint(vararg items: Any?) { | ||||||
|  | @ -905,9 +1021,11 @@ object VideoDownloadManager { | ||||||
|         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 relativePath = getRelativePath(folder) | ||||||
|         val displayName = getDisplayName(name, extension) |         val displayName = getDisplayName(name, extension) | ||||||
| 
 | 
 | ||||||
|  |         val basePath = context.getBasePath() | ||||||
|  | 
 | ||||||
|         val fileStream = stream.fileStream!! |         val fileStream = stream.fileStream!! | ||||||
| 
 | 
 | ||||||
|         val firstTs = tsIterator.next() |         val firstTs = tsIterator.next() | ||||||
|  | @ -920,7 +1038,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, folder, 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 +1057,15 @@ object VideoDownloadManager { | ||||||
|                     it.toString(), |                     it.toString(), | ||||||
|                     DownloadedFileInfo( |                     DownloadedFileInfo( | ||||||
|                         (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), |                         (bytesDownloaded * (totalTs / tsProgress.toFloat())).toLong(), | ||||||
|                         relativePath, |                         folder ?: "", | ||||||
|                         displayName, |                         displayName, | ||||||
|                         tsProgress.toString() |                         tsProgress.toString(), | ||||||
|  |                         basePath = basePath.second | ||||||
|                     ) |                     ) | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  | 
 | ||||||
|         updateInfo() |         updateInfo() | ||||||
| 
 | 
 | ||||||
|         fun updateNotification() { |         fun updateNotification() { | ||||||
|  | @ -1217,8 +1337,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 +1347,12 @@ 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 file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName) | ||||||
|             val dFile = File(normalPath) | //            val normalPath = context.getNormalPath(getFile(info.relativePath), info.displayName) | ||||||
|             if (!dFile.exists()) return null | //            val dFile = File(normalPath) | ||||||
|             return DownloadedFileInfoResult(dFile.length(), info.totalBytes, dFile.toUri()) |             if (file?.exists() != true) return null | ||||||
|  | 
 | ||||||
|  |             return DownloadedFileInfoResult(file.length(), info.totalBytes, file.uri) | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -1245,8 +1368,9 @@ 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 +1378,11 @@ 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 file.delete() | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -1272,19 +1397,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) | ||||||
|  |  | ||||||
|  | @ -24,6 +24,10 @@ | ||||||
|     <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> | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|     <!-- FORMAT MIGHT TRANSLATE, WILL CAUSE CRASH IF APPLIED WRONG --> |     <!-- FORMAT MIGHT TRANSLATE, WILL CAUSE CRASH IF APPLIED WRONG --> | ||||||
|  | @ -267,6 +271,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> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -99,6 +99,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> | ||||||
|     </PreferenceCategory> |     </PreferenceCategory> | ||||||
|     <PreferenceCategory |     <PreferenceCategory | ||||||
|             android:key="search" |             android:key="search" | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue