fixed resume download + migrated filesystem to SafeFile

This commit is contained in:
LagradOst 2023-08-23 06:25:06 +02:00
parent afcbdeecc8
commit 3ea6b1a8d5
8 changed files with 924 additions and 260 deletions

View file

@ -6,11 +6,11 @@ import android.os.Bundle
import android.util.Log import android.util.Log
import android.view.KeyEvent import android.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.ExtractorUri import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.storage.SafeFile
const val DTAG = "PlayerActivity" const val DTAG = "PlayerActivity"
@ -50,7 +50,7 @@ class DownloadedPlayerActivity : AppCompatActivity() {
} }
private fun playUri(uri: Uri) { private fun playUri(uri: Uri) {
val name = UniFile.fromUri(this, uri).name val name = SafeFile.fromUri(this, uri)?.name()
this.navigate( this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance( R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
DownloadFileGenerator( DownloadFileGenerator(

View file

@ -52,6 +52,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.storage.SafeFile
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
@ -525,10 +526,11 @@ class GeneratorPlayer : FullScreenPlayer() {
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
) )
val file = UniFile.fromUri(ctx, uri) val file = SafeFile.fromUri(ctx, uri)
println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}") val fileName = file?.name()
println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName")
// DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES
val name = file.name ?: uri.toString() val name = fileName ?: uri.toString()
val subtitleData = SubtitleData( val subtitleData = SubtitleData(
name, name,

View file

@ -3,9 +3,7 @@ package com.lagradost.cloudstream3.ui.settings
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.view.View import android.view.View
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -13,7 +11,6 @@ import androidx.appcompat.app.AlertDialog
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.allProviders
import com.lagradost.cloudstream3.AcraApplication import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
@ -41,7 +38,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
import com.lagradost.cloudstream3.utils.VideoDownloadManager import com.lagradost.cloudstream3.utils.VideoDownloadManager
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath
import java.io.File import com.lagradost.cloudstream3.utils.storage.SafeFile
fun getCurrentLocale(context: Context): String { fun getCurrentLocale(context: Context): String {
val res = context.resources val res = context.resources
@ -139,8 +136,9 @@ class SettingsGeneral : PreferenceFragmentCompat() {
context.contentResolver.takePersistableUriPermission(uri, flags) context.contentResolver.takePersistableUriPermission(uri, flags)
val file = UniFile.fromUri(context, uri) val file = SafeFile.fromUri(context, uri)
println("Selected URI path: $uri - Full path: ${file.filePath}") val filePath = file?.filePath()
println("Selected URI path: $uri - Full path: $filePath")
// Stores the real URI using download_path_key // Stores the real URI using download_path_key
// Important that the URI is stored instead of filepath due to permissions. // Important that the URI is stored instead of filepath due to permissions.
@ -149,7 +147,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
// From URI -> File path // From URI -> File path
// File path here is purely for cosmetic purposes in settings // File path here is purely for cosmetic purposes in settings
(file.filePath ?: uri.toString()).let { (filePath ?: uri.toString()).let {
PreferenceManager.getDefaultSharedPreferences(context) PreferenceManager.getDefaultSharedPreferences(context)
.edit().putString(getString(R.string.download_path_pref), it).apply() .edit().putString(getString(R.string.download_path_pref), it).apply()
} }
@ -306,25 +304,23 @@ class SettingsGeneral : PreferenceFragmentCompat() {
} }
return@setOnPreferenceClickListener true return@setOnPreferenceClickListener true
} }
fun getDownloadDirs(): List<String> { fun getDownloadDirs(): List<String> {
return normalSafeApiCall { return normalSafeApiCall {
val defaultDir = VideoDownloadManager.getDownloadDir()?.filePath context?.let { ctx ->
val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath()
// app_name_download_path = Cloudstream and does not change depending on release. val first = listOf(defaultDir)
// DOES NOT WORK ON SCOPED STORAGE. (try {
val secondaryDir = val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) null else Environment.getExternalStorageDirectory().absolutePath +
File.separator + resources.getString(R.string.app_name_download_path)
val first = listOf(defaultDir, secondaryDir)
(try {
val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second }
(first + (first +
requireContext().getExternalFilesDirs("").mapNotNull { it.path } + ctx.getExternalFilesDirs("").mapNotNull { it.path } +
currentDir) currentDir)
} catch (e: Exception) { } catch (e: Exception) {
first first
}).filterNotNull().distinct() }).filterNotNull().distinct()
}
} ?: emptyList() } ?: emptyList()
} }
@ -339,7 +335,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
val currentDir = val currentDir =
settingsManager.getString(getString(R.string.download_path_pref), null) settingsManager.getString(getString(R.string.download_path_pref), null)
?: VideoDownloadManager.getDownloadDir().toString() ?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx).toString() }
activity?.showBottomDialog( activity?.showBottomDialog(
dirs + listOf("Custom"), dirs + listOf("Custom"),

View file

@ -1,11 +1,8 @@
package com.lagradost.cloudstream3.utils package com.lagradost.cloudstream3.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -36,9 +33,9 @@ import com.lagradost.cloudstream3.utils.DataStore.mapper
import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.UIHelper.requestRW
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream
import com.lagradost.cloudstream3.utils.VideoDownloadManager.isDownloadDir import okhttp3.internal.closeQuietly
import java.io.IOException import java.io.OutputStream
import java.io.PrintWriter import java.io.PrintWriter
import java.lang.System.currentTimeMillis import java.lang.System.currentTimeMillis
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -147,6 +144,8 @@ object BackupUtils {
@SuppressLint("SimpleDateFormat") @SuppressLint("SimpleDateFormat")
fun FragmentActivity.backup() { fun FragmentActivity.backup() {
var fileStream: OutputStream? = null
var printStream: PrintWriter? = null
try { try {
if (!checkWrite()) { if (!checkWrite()) {
showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG) showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG)
@ -154,13 +153,16 @@ object BackupUtils {
return return
} }
val subDir = getBasePath().first
val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis())) val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis()))
val ext = "json" val ext = "json"
val displayName = "CS3_Backup_${date}" val displayName = "CS3_Backup_${date}"
val backupFile = getBackup() val backupFile = getBackup()
val stream = setupStream(this, displayName, null, ext, false)
fileStream = stream.openNew()
printStream = PrintWriter(fileStream)
printStream.print(mapper.writeValueAsString(backupFile))
val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q /*val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
&& subDir?.isDownloadDir() == true && subDir?.isDownloadDir() == true
) { ) {
val cr = this.contentResolver val cr = this.contentResolver
@ -198,7 +200,7 @@ object BackupUtils {
val printStream = PrintWriter(steam) val printStream = PrintWriter(steam)
printStream.print(mapper.writeValueAsString(backupFile)) printStream.print(mapper.writeValueAsString(backupFile))
printStream.close() printStream.close()*/
showToast( showToast(
R.string.backup_success, R.string.backup_success,
@ -214,6 +216,9 @@ object BackupUtils {
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
} }
} finally {
printStream?.closeQuietly()
fileStream?.closeQuietly()
} }
} }

View file

@ -8,7 +8,6 @@ import android.content.*
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
@ -20,7 +19,6 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager import androidx.work.WorkManager
import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.annotation.JsonProperty
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
@ -31,19 +29,19 @@ import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
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
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
import com.lagradost.cloudstream3.utils.storage.MediaFileContentType
import com.lagradost.cloudstream3.utils.storage.SafeFile
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -160,24 +158,33 @@ object VideoDownloadManager {
@JsonProperty("pkg") val pkg: DownloadResumePackage, @JsonProperty("pkg") val pkg: DownloadResumePackage,
) )
private const val SUCCESS_DOWNLOAD_DONE = 1 data class DownloadStatus(
private const val SUCCESS_STREAM = 3 /** if you should retry with the same args and hope for a better result */
private const val SUCCESS_STOPPED = 2 val retrySame: Boolean,
/** if you should try the next mirror */
val tryNext: Boolean,
/** if the result is what the user intended */
val success: Boolean,
)
// will not download the next one, but is still classified as an error /** Invalid input, just skip to the next one as the same args will give the same error */
private const val ERROR_DELETING_FILE = 3 private val DOWNLOAD_INVALID_INPUT =
private const val ERROR_CREATE_FILE = -2 DownloadStatus(retrySame = false, tryNext = true, success = false)
private const val ERROR_UNKNOWN = -10
//private const val ERROR_OPEN_FILE = -3 /** no need to try any other mirror as we have downloaded the file */
private const val ERROR_TOO_SMALL_CONNECTION = -4 private val DOWNLOAD_SUCCESS =
DownloadStatus(retrySame = false, tryNext = false, success = true)
//private const val ERROR_WRONG_CONTENT = -5 /** the user pressed stop, so no need to download anything else */
private const val ERROR_CONNECTION_ERROR = -6 private val DOWNLOAD_STOPPED =
DownloadStatus(retrySame = false, tryNext = false, success = true)
//private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7 /** the process failed due to some reason, so we retry and also try the next mirror */
//private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8 private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false)
private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9
/** bad config, skip all mirrors as every call to download will have the same bad config */
private val DOWNLOAD_BAD_CONFIG =
DownloadStatus(retrySame = false, tryNext = false, success = false)
private const val KEY_RESUME_PACKAGES = "download_resume" private const val KEY_RESUME_PACKAGES = "download_resume"
const val KEY_DOWNLOAD_INFO = "download_info" const val KEY_DOWNLOAD_INFO = "download_info"
@ -209,15 +216,15 @@ object VideoDownloadManager {
} }
} }
/** Will return IsDone if not found or error */ ///** Will return IsDone if not found or error */
fun getDownloadState(id: Int): DownloadType { //fun getDownloadState(id: Int): DownloadType {
return try { // return try {
downloadStatus[id] ?: DownloadType.IsDone // downloadStatus[id] ?: DownloadType.IsDone
} catch (e: Exception) { // } catch (e: Exception) {
logError(e) // logError(e)
DownloadType.IsDone // DownloadType.IsDone
} // }
} //}
private val cachedBitmaps = hashMapOf<String, Bitmap>() private val cachedBitmaps = hashMapOf<String, Bitmap>()
fun Context.getImageBitmapFromUrl(url: String, headers: Map<String, String>? = null): Bitmap? { fun Context.getImageBitmapFromUrl(url: String, headers: Map<String, String>? = null): Bitmap? {
@ -302,7 +309,7 @@ object VideoDownloadManager {
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false) builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false)
} else if (state == DownloadType.IsPending) { } else if (state == DownloadType.IsPending) {
builder.setProgress(0,0,true) builder.setProgress(0, 0, true)
} }
val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else ""
@ -496,10 +503,11 @@ object VideoDownloadManager {
basePath: String? basePath: String?
): List<Pair<String, Uri>>? { ): List<Pair<String, Uri>>? {
val base = basePathToFile(context, basePath) val base = basePathToFile(context, basePath)
val folder = base?.gotoDir(relativePath, false) ?: return null val folder = base?.gotoDirectory(relativePath, false) ?: return null
if (!folder.isDirectory) return null if (folder.isDirectory() != false) return null
return folder.listFiles()?.map { (it.name ?: "") to it.uri } return folder.listFiles()
?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) }
} }
@ -514,37 +522,29 @@ object VideoDownloadManager {
data class StreamData( data class StreamData(
private val fileLength: Long, private val fileLength: Long,
val file: UniFile, val file: SafeFile,
//val fileStream: OutputStream, //val fileStream: OutputStream,
) { ) {
fun open() : OutputStream { @Throws(IOException::class)
return file.openOutputStream(resume) fun open(): OutputStream {
return file.openOutputStreamOrThrow(resume)
} }
fun openNew() : OutputStream { @Throws(IOException::class)
return file.openOutputStream(false) fun openNew(): OutputStream {
return file.openOutputStreamOrThrow(false)
}
fun delete(): Boolean {
return file.delete()
} }
val resume: Boolean get() = fileLength > 0L val resume: Boolean get() = fileLength > 0L
val startAt: Long get() = if (resume) fileLength else 0L val startAt: Long get() = if (resume) fileLength else 0L
val exists: Boolean get() = file.exists() val exists: Boolean get() = file.exists() == true
} }
//class ADownloadException(val id: Int) : RuntimeException(message = "Download error $id")
fun UniFile.createFileOrThrow(displayName: String): UniFile {
return this.createFile(displayName) ?: throw IOException("Could not create file")
}
fun UniFile.deleteOrThrow() {
if (!this.delete()) throw IOException("Could not delete file")
}
/**
* Sets up the appropriate file and creates a data stream from the file.
* Used for initializing downloads.
* */
@Throws(IOException::class) @Throws(IOException::class)
fun setupStream( fun setupStream(
context: Context, context: Context,
@ -552,19 +552,39 @@ object VideoDownloadManager {
folder: String?, folder: String?,
extension: String, extension: String,
tryResume: Boolean, tryResume: Boolean,
): StreamData {
val (base, _) = context.getBasePath()
return setupStream(
base ?: throw IOException("Bad config"),
name,
folder,
extension,
tryResume
)
}
/**
* Sets up the appropriate file and creates a data stream from the file.
* Used for initializing downloads.
* */
@Throws(IOException::class)
fun setupStream(
baseFile: SafeFile,
name: String,
folder: String?,
extension: String,
tryResume: Boolean,
): StreamData { ): StreamData {
val displayName = getDisplayName(name, extension) val displayName = getDisplayName(name, extension)
val (baseFile, _) = context.getBasePath() val subDir = baseFile.gotoDirectoryOrThrow(folder)
val subDir = baseFile?.gotoDir(folder) ?: throw IOException()
val foundFile = subDir.findFile(displayName) val foundFile = subDir.findFile(displayName)
val (file, fileLength) = if (foundFile == null || !foundFile.exists()) { val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) {
subDir.createFileOrThrow(displayName) to 0L subDir.createFileOrThrow(displayName) to 0L
} else { } else {
if (tryResume) { if (tryResume) {
foundFile to foundFile.size() foundFile to foundFile.lengthOrThrow()
} else { } else {
foundFile.deleteOrThrow() foundFile.deleteOrThrow()
subDir.createFileOrThrow(displayName) to 0L subDir.createFileOrThrow(displayName) to 0L
@ -1004,21 +1024,20 @@ object VideoDownloadManager {
} }
} }
@Throws
suspend fun downloadThing( suspend fun downloadThing(
context: Context, context: Context,
link: IDownloadableMinimum, link: IDownloadableMinimum,
name: String, name: String,
folder: String?, folder: String,
extension: String, extension: String,
tryResume: Boolean, tryResume: Boolean,
parentId: Int?, parentId: Int?,
createNotificationCallback: (CreateNotificationMetadata) -> Unit, createNotificationCallback: (CreateNotificationMetadata) -> Unit,
parallelConnections: Int = 3 parallelConnections: Int = 3
): Int = withContext(Dispatchers.IO) { ): DownloadStatus = withContext(Dispatchers.IO) {
// we cant download torrents with this implementation, aria2c might be used in the future // we cant download torrents with this implementation, aria2c might be used in the future
if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) { if (link.url.startsWith("magnet") || link.url.endsWith(".torrent") || parallelConnections < 1) {
return@withContext ERROR_UNKNOWN return@withContext DOWNLOAD_INVALID_INPUT
} }
var fileStream: OutputStream? = null var fileStream: OutputStream? = null
@ -1033,13 +1052,10 @@ object VideoDownloadManager {
// get the file path // get the file path
val (baseFile, basePath) = context.getBasePath() val (baseFile, basePath) = context.getBasePath()
val displayName = getDisplayName(name, extension) val displayName = getDisplayName(name, extension)
val relativePath = if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
folder
) else folder
// set up the download file // set up the download file
val stream = setupStream(context, name, relativePath, extension, tryResume) val stream = setupStream(baseFile, name, folder, extension, tryResume)
fileStream = stream.open() fileStream = stream.open()
@ -1069,7 +1085,7 @@ object VideoDownloadManager {
metadata.setDownloadFileInfoTemplate( metadata.setDownloadFileInfoTemplate(
DownloadedFileInfo( DownloadedFileInfo(
totalBytes = metadata.approxTotalBytes, totalBytes = metadata.approxTotalBytes,
relativePath = relativePath ?: "", relativePath = folder,
displayName = displayName, displayName = displayName,
basePath = basePath basePath = basePath
) )
@ -1202,19 +1218,19 @@ object VideoDownloadManager {
if (!stream.exists) metadata.type = DownloadType.IsStopped if (!stream.exists) metadata.type = DownloadType.IsStopped
if (metadata.type == DownloadType.IsFailed) { if (metadata.type == DownloadType.IsFailed) {
return@withContext ERROR_CONNECTION_ERROR return@withContext DOWNLOAD_FAILED
} }
if (metadata.type == DownloadType.IsStopped) { if (metadata.type == DownloadType.IsStopped) {
// we need to close before delete // we need to close before delete
fileStream.closeQuietly() fileStream.closeQuietly()
metadata.onDelete() metadata.onDelete()
deleteFile(context, baseFile, relativePath ?: "", displayName) stream.delete()
return@withContext SUCCESS_STOPPED return@withContext DOWNLOAD_STOPPED
} }
metadata.type = DownloadType.IsDone metadata.type = DownloadType.IsDone
return@withContext SUCCESS_DOWNLOAD_DONE return@withContext DOWNLOAD_SUCCESS
} catch (e: IOException) { } catch (e: IOException) {
// some sort of IO error, this should not happened // some sort of IO error, this should not happened
// we just rethrow it // we just rethrow it
@ -1226,7 +1242,7 @@ object VideoDownloadManager {
// note that when failing we don't want to delete the file, // note that when failing we don't want to delete the file,
// only user interaction has that power // only user interaction has that power
metadata.type = DownloadType.IsFailed metadata.type = DownloadType.IsFailed
return@withContext ERROR_CONNECTION_ERROR return@withContext DOWNLOAD_FAILED
} finally { } finally {
fileStream?.closeQuietly() fileStream?.closeQuietly()
//requestStream?.closeQuietly() //requestStream?.closeQuietly()
@ -1234,39 +1250,36 @@ object VideoDownloadManager {
} }
} }
@Throws
private suspend fun downloadHLS( private suspend fun downloadHLS(
context: Context, context: Context,
link: ExtractorLink, link: ExtractorLink,
name: String, name: String,
folder: String?, folder: String,
parentId: Int?, parentId: Int?,
startIndex: Int?, startIndex: Int?,
createNotificationCallback: (CreateNotificationMetadata) -> Unit, createNotificationCallback: (CreateNotificationMetadata) -> Unit,
parallelConnections: Int = 3 parallelConnections: Int = 3
): Int = withContext(Dispatchers.IO) { ): DownloadStatus = withContext(Dispatchers.IO) {
require(parallelConnections >= 1) if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT
val metadata = DownloadMetaData( val metadata = DownloadMetaData(
createNotificationCallback = createNotificationCallback, createNotificationCallback = createNotificationCallback,
id = parentId id = parentId
) )
val extension = "mp4"
var fileStream: OutputStream? = null var fileStream: OutputStream? = null
try { try {
val extension = "mp4"
// the start .ts index // the start .ts index
var startAt = startIndex ?: 0 var startAt = startIndex ?: 0
// set up the file data // set up the file data
val (baseFile, basePath) = context.getBasePath() val (baseFile, basePath) = context.getBasePath()
val relativePath = if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
folder
) else folder
val displayName = getDisplayName(name, extension) val displayName = getDisplayName(name, extension)
val stream = val stream =
setupStream(context, name, relativePath, extension, startAt > 0) setupStream(baseFile, name, folder, extension, startAt > 0)
if (!stream.resume) startAt = 0 if (!stream.resume) startAt = 0
fileStream = stream.open() fileStream = stream.open()
@ -1277,7 +1290,7 @@ object VideoDownloadManager {
metadata.setDownloadFileInfoTemplate( metadata.setDownloadFileInfoTemplate(
DownloadedFileInfo( DownloadedFileInfo(
totalBytes = 0, totalBytes = 0,
relativePath = relativePath ?: "", relativePath = folder,
displayName = displayName, displayName = displayName,
basePath = basePath basePath = basePath
) )
@ -1406,99 +1419,29 @@ object VideoDownloadManager {
if (!stream.exists) metadata.type = DownloadType.IsStopped if (!stream.exists) metadata.type = DownloadType.IsStopped
if (metadata.type == DownloadType.IsFailed) { if (metadata.type == DownloadType.IsFailed) {
return@withContext ERROR_CONNECTION_ERROR return@withContext DOWNLOAD_FAILED
} }
if (metadata.type == DownloadType.IsStopped) { if (metadata.type == DownloadType.IsStopped) {
// we need to close before delete // we need to close before delete
fileStream.closeQuietly() fileStream.closeQuietly()
metadata.onDelete() metadata.onDelete()
deleteFile(context, baseFile, relativePath ?: "", displayName) stream.delete()
return@withContext SUCCESS_STOPPED return@withContext DOWNLOAD_STOPPED
} }
metadata.type = DownloadType.IsDone metadata.type = DownloadType.IsDone
return@withContext SUCCESS_DOWNLOAD_DONE return@withContext DOWNLOAD_SUCCESS
} catch (t: Throwable) { } catch (t: Throwable) {
logError(t) logError(t)
metadata.type = DownloadType.IsFailed metadata.type = DownloadType.IsFailed
return@withContext ERROR_UNKNOWN return@withContext DOWNLOAD_FAILED
} finally { } finally {
fileStream?.closeQuietly() fileStream?.closeQuietly()
metadata.close() metadata.close()
} }
} }
/**
* 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? {
if(directoryName == null) return this
return directoryName.split(File.separatorChar).filter { it.isNotBlank() }.fold(this) { file: UniFile?, directory ->
file?.createDirectory(directory)
}
// 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"
} }
@ -1510,33 +1453,22 @@ object VideoDownloadManager {
* As of writing UniFile is used for everything but download directory on scoped storage. * 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. * Special ContentResolver fuckery is needed for that as UniFile doesn't work.
* */ * */
fun getDownloadDir(): UniFile? { fun getDefaultDir(context: Context): SafeFile? {
// See https://www.py4u.net/discuss/614761 // See https://www.py4u.net/discuss/614761
return UniFile.fromFile( return SafeFile.fromMedia(
File( context, MediaFileContentType.Downloads
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
).replace("${File.separatorChar}${File.separatorChar}", File.separatorChar.toString())
}
/** /**
* Turns a string to an UniFile. Used for stored string paths such as settings. * Turns a string to an UniFile. Used for stored string paths such as settings.
* Should only be used to get a download path. * Should only be used to get a download path.
* */ * */
private fun basePathToFile(context: Context, path: String?): UniFile? { private fun basePathToFile(context: Context, path: String?): SafeFile? {
return when { return when {
path.isNullOrBlank() -> getDownloadDir() path.isNullOrBlank() -> getDefaultDir(context)
path.startsWith("content://") -> UniFile.fromUri(context, path.toUri()) path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri())
else -> UniFile.fromFile(File(path)) else -> SafeFile.fromFile(context, File(path))
} }
} }
@ -1545,17 +1477,12 @@ object VideoDownloadManager {
* Returns the file and a string to be stored for future file retrieval. * Returns the file and a string to be stored for future file retrieval.
* UniFile.filePath is not sufficient for storage. * UniFile.filePath is not sufficient for storage.
* */ * */
fun Context.getBasePath(): Pair<UniFile?, String?> { fun Context.getBasePath(): Pair<SafeFile?, String?> {
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null)
return basePathToFile(this, basePathSetting) to basePathSetting return basePathToFile(this, basePathSetting) to basePathSetting
} }
fun UniFile?.isDownloadDir(): Boolean {
return this != null && this.filePath == getDownloadDir()?.filePath
}
fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String { fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String {
return getFileName(context, metadata.name, metadata.episode, metadata.season) return getFileName(context, metadata.name, metadata.episode, metadata.season)
} }
@ -1596,7 +1523,7 @@ object VideoDownloadManager {
link: ExtractorLink, link: ExtractorLink,
notificationCallback: (Int, Notification) -> Unit, notificationCallback: (Int, Notification) -> Unit,
tryResume: Boolean = false, tryResume: Boolean = false,
): Int { ): DownloadStatus {
val name = getFileName(context, ep) val name = getFileName(context, ep)
// Make sure this is cancelled when download is done or cancelled. // Make sure this is cancelled when download is done or cancelled.
@ -1638,7 +1565,7 @@ object VideoDownloadManager {
context, context,
link, link,
name, name,
folder, folder ?: "",
ep.id, ep.id,
startIndex, startIndex,
callback callback
@ -1648,7 +1575,7 @@ object VideoDownloadManager {
context, context,
link, link,
name, name,
folder, folder ?: "",
"mp4", "mp4",
tryResume, tryResume,
ep.id, ep.id,
@ -1656,7 +1583,7 @@ object VideoDownloadManager {
) )
} }
} catch (t: Throwable) { } catch (t: Throwable) {
return ERROR_UNKNOWN return DOWNLOAD_FAILED
} finally { } finally {
extractorJob.cancel() extractorJob.cancel()
} }
@ -1698,10 +1625,8 @@ object VideoDownloadManager {
notificationCallback, notificationCallback,
resume resume
) )
//.also { println("Single episode finished with return code: $it") }
// retry every link at least once if (connectionResult.retrySame) {
if (connectionResult <= 0) {
connectionResult = downloadSingleEpisode( connectionResult = downloadSingleEpisode(
context, context,
item.source, item.source,
@ -1713,11 +1638,12 @@ object VideoDownloadManager {
) )
} }
if (connectionResult > 0) { // SUCCESS if (connectionResult.success) { // SUCCESS
removeKey(KEY_RESUME_PACKAGES, id.toString()) removeKey(KEY_RESUME_PACKAGES, id.toString())
break break
} else if (index == item.links.lastIndex) { } else if (!connectionResult.tryNext || index >= item.links.lastIndex) {
downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed)) downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed))
break
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -1731,62 +1657,69 @@ object VideoDownloadManager {
// return id // return id
} }
fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? { /* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
val res = getDownloadFileInfo(context, id) val res = getDownloadFileInfo(context, id)
if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return res return res
}
*/
fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? =
getDownloadFileInfo(context, id, removeKeys = true)
private fun DownloadedFileInfo.toFile(context: Context): SafeFile? {
return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath)
?.findFile(displayName)
} }
private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? { private fun getDownloadFileInfo(
context: Context,
id: Int,
removeKeys: Boolean = false
): DownloadedFileInfoResult? {
try { try {
val info = val info =
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return null context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return null
val base = basePathToFile(context, info.basePath) val file = info.toFile(context)
val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName)
if (file?.exists() != true) return null
return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri) // only delete the key if the file is not found
if (file == null || !file.existsOrThrow()) {
if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return null
}
return DownloadedFileInfoResult(
file.lengthOrThrow(),
info.totalBytes,
file.uriOrThrow()
)
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
return null return null
} }
} }
/**
* Gets the true download size as Scoped Storage sometimes wrongly returns 0.
* */
fun UniFile.size(): Long {
val len = length()
return if (len <= 1) {
println("LEN:::::::>>>>>>>>>>>>>>>>>>>>>>>$len")
val inputStream = this.openInputStream()
return inputStream.available().toLong().also { inputStream.closeQuietly() }
} else {
len
}
}
fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
val success = deleteFile(context, id) val success = deleteFile(context, id)
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return success return success
} }
private fun deleteFile( /*private fun deleteFile(
context: Context, context: Context,
folder: UniFile?, folder: SafeFile?,
relativePath: String, relativePath: String,
displayName: String displayName: String
): Boolean { ): Boolean {
val file = folder?.gotoDir(relativePath)?.findFile(displayName) ?: return false val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false
if (!file.exists()) return true if (file.exists() == false) return true
return try { return try {
file.delete() file.delete()
} catch (e: Exception) { } catch (e: Exception) {
logError(e) logError(e)
(context.contentResolver?.delete(file.uri, null, null) ?: return false) > 0 (context.contentResolver?.delete(file.uri() ?: return true, null, null)
?: return false) > 0
} }
} }*/
private fun deleteFile(context: Context, id: Int): Boolean { private fun deleteFile(context: Context, id: Int): Boolean {
val info = val info =
@ -1795,8 +1728,7 @@ object VideoDownloadManager {
downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadProgressEvent.invoke(Triple(id, 0, 0))
downloadStatusEvent.invoke(id to DownloadType.IsStopped) downloadStatusEvent.invoke(id to DownloadType.IsStopped)
downloadDeleteEvent.invoke(id) downloadDeleteEvent.invoke(id)
val base = basePathToFile(context, info.basePath) return info.toFile(context)?.delete() ?: false
return deleteFile(context, base, info.relativePath, info.displayName)
} }
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {

View file

@ -0,0 +1,369 @@
package com.lagradost.cloudstream3.utils.storage
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import com.hippo.unifile.UniRandomAccessFile
import com.lagradost.cloudstream3.mvvm.logError
import okhttp3.internal.closeQuietly
import java.io.File
import java.io.InputStream
import java.io.OutputStream
enum class MediaFileContentType {
Downloads,
Audio,
Video,
Images,
}
// https://developer.android.com/training/data-storage/shared/media
fun MediaFileContentType.toPath(): String {
return when (this) {
MediaFileContentType.Downloads -> Environment.DIRECTORY_DOWNLOADS
MediaFileContentType.Audio -> Environment.DIRECTORY_MUSIC
MediaFileContentType.Video -> Environment.DIRECTORY_MOVIES
MediaFileContentType.Images -> Environment.DIRECTORY_DCIM
}
}
fun MediaFileContentType.defaultPrefix(): String {
return Environment.getExternalStorageDirectory().absolutePath
}
fun MediaFileContentType.toAbsolutePath(): String {
return defaultPrefix() + File.separator +
this.toPath()
}
fun replaceDuplicateFileSeparators(path: String): String {
return path.replace(Regex("${File.separator}+"), File.separator)
}
@RequiresApi(Build.VERSION_CODES.Q)
fun MediaFileContentType.toUri(external: Boolean): Uri {
val volume = if (external) MediaStore.VOLUME_EXTERNAL_PRIMARY else MediaStore.VOLUME_INTERNAL
return when (this) {
MediaFileContentType.Downloads -> MediaStore.Downloads.getContentUri(volume)
MediaFileContentType.Audio -> MediaStore.Audio.Media.getContentUri(volume)
MediaFileContentType.Video -> MediaStore.Video.Media.getContentUri(volume)
MediaFileContentType.Images -> MediaStore.Images.Media.getContentUri(volume)
}
}
@RequiresApi(Build.VERSION_CODES.Q)
class MediaFile(
private val context: Context,
private val folderType: MediaFileContentType,
private val external: Boolean = true,
absolutePath: String,
) : SafeFile {
// this is the path relative to the download directory so "/hello/text.txt" = "hello/text.txt" is in fact "Download/hello/text.txt"
private val sanitizedAbsolutePath: String =
replaceDuplicateFileSeparators(absolutePath)
// this is only a directory if the filepath ends with a /
private val isDir: Boolean = sanitizedAbsolutePath.endsWith(File.separator)
private val isFile: Boolean = !isDir
// this is the relative path including the Download directory, so "/hello/text.txt" => "Download/hello"
private val relativePath: String =
replaceDuplicateFileSeparators(folderType.toPath() + File.separator + sanitizedAbsolutePath).substringBeforeLast(
File.separator
)
// "/hello/text.txt" => "text.txt"
private val namePath: String = sanitizedAbsolutePath.substringAfterLast(File.separator)
private val baseUri = folderType.toUri(external)
private val contentResolver: ContentResolver = context.contentResolver
init {
// some standard asserts that should always be hold or else this class wont work
assert(!relativePath.endsWith(File.separator))
assert(!(isDir && isFile))
assert(!relativePath.contains(File.separator + File.separator))
assert(!namePath.contains(File.separator))
if (isDir) {
assert(namePath.isBlank())
} else {
assert(namePath.isNotBlank())
}
}
companion object {
private fun splitFilenameExt(name: String): Pair<String, String?> {
val split = name.indexOfLast { it == '.' }
if (split <= 0) return name to null
val ext = name.substring(split + 1 until name.length)
if (ext.isBlank()) return name to null
return name.substring(0 until split) to ext
}
private fun splitFilenameMime(name: String): Pair<String, String?> {
val (display, ext) = splitFilenameExt(name)
val mimeType = when (ext) {
// Absolutely ridiculous, if text/vtt is used as mimetype scoped storage prevents
// downloading to /Downloads yet it works with null
"vtt" -> null // "text/vtt"
"mp4" -> "video/mp4"
"srt" -> null // "application/x-subrip"//"text/plain"
else -> null
}
return display to mimeType
}
}
private fun appendRelativePath(path: String, folder: Boolean): MediaFile? {
if (isFile) return null
// VideoDownloadManager.sanitizeFilename(path.replace(File.separator, ""))
val newPath =
sanitizedAbsolutePath + path + if (folder) File.separator else ""
return MediaFile(
context = context,
folderType = folderType,
external = external,
absolutePath = newPath
)
}
private fun createUri(displayName: String? = namePath): Uri? {
if (displayName == null) return null
if (isFile) return null
val (name, mime) = splitFilenameMime(displayName)
val newFile = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.TITLE, name)
if (mime != null)
put(MediaStore.MediaColumns.MIME_TYPE, mime)
put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath)
}
return contentResolver.insert(baseUri, newFile)
}
override fun createFile(displayName: String?): SafeFile? {
if (isFile || displayName == null) return null
query(displayName)?.uri ?: createUri(displayName) ?: return null
return appendRelativePath(displayName, false) //SafeFile.fromUri(context, ?: return null)
}
override fun createDirectory(directoryName: String?): SafeFile? {
if (directoryName == null) return null
// we don't create a dir here tbh, just fake create it
return appendRelativePath(directoryName, true)
}
private data class QueryResult(
val uri: Uri,
val lastModified: Long,
val length: Long,
)
@RequiresApi(Build.VERSION_CODES.Q)
private fun query(displayName: String = namePath): QueryResult? {
try {
//val (name, mime) = splitFilenameMime(fullName)
val projection = arrayOf(
MediaStore.MediaColumns._ID,
MediaStore.MediaColumns.DATE_MODIFIED,
MediaStore.MediaColumns.SIZE,
)
val selection =
"${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}' AND ${MediaStore.MediaColumns.DISPLAY_NAME}='$displayName'"
contentResolver.query(
baseUri,
projection, selection, null, null
)?.use { cursor ->
while (cursor.moveToNext()) {
val id =
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
return QueryResult(
uri = ContentUris.withAppendedId(
baseUri, id
),
lastModified = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_MODIFIED)),
length = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.SIZE)),
)
}
}
} catch (t: Throwable) {
logError(t)
}
return null
}
override fun uri(): Uri? {
return query()?.uri
}
override fun name(): String? {
if (isDir) return null
return namePath
}
override fun type(): String? {
TODO("Not yet implemented")
}
override fun filePath(): String {
return replaceDuplicateFileSeparators(relativePath + File.separator + namePath)
}
override fun isDirectory(): Boolean {
return isDir
}
override fun isFile(): Boolean {
return isFile
}
override fun lastModified(): Long? {
if (isDir) return null
return query()?.lastModified
}
override fun length(): Long? {
if (isDir) return null
val length = query()?.length ?: return null
if(length <= 0) {
val inputStream : InputStream = openInputStream() ?: return null
return try {
inputStream.available().toLong()
} catch (t : Throwable) {
null
} finally {
inputStream.closeQuietly()
}
}
return length
}
override fun canRead(): Boolean {
TODO("Not yet implemented")
}
override fun canWrite(): Boolean {
TODO("Not yet implemented")
}
private fun delete(uri: Uri): Boolean {
return contentResolver.delete(uri, null, null) > 0
}
override fun delete(): Boolean {
return if (isDir) {
(listFiles() ?: return false).all {
it.delete()
}
} else {
delete(uri() ?: return false)
}
}
override fun exists(): Boolean {
if (isDir) return true
return query() != null
}
override fun listFiles(): List<SafeFile>? {
if (isFile) return null
try {
val projection = arrayOf(
MediaStore.MediaColumns.DISPLAY_NAME
)
val selection =
"${MediaStore.MediaColumns.RELATIVE_PATH}='$relativePath${File.separator}'"
contentResolver.query(
baseUri,
projection, selection, null, null
)?.use { cursor ->
val out = ArrayList<SafeFile>(cursor.count)
while (cursor.moveToNext()) {
val nameIdx = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME)
if (nameIdx == -1) continue
val name = cursor.getString(nameIdx)
appendRelativePath(name, false)?.let { new ->
out.add(new)
}
}
out
}
} catch (t: Throwable) {
logError(t)
}
return null
}
override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? {
if (isFile || displayName == null) return null
val new = appendRelativePath(displayName, false) ?: return null
if (new.exists()) {
return new
}
return null//SafeFile.fromUri(context, query(displayName ?: return null)?.uri ?: return null)
}
override fun renameTo(name: String?): Boolean {
TODO("Not yet implemented")
}
override fun openOutputStream(append: Boolean): OutputStream? {
try {
// use current file
uri()?.let {
return contentResolver.openOutputStream(
it,
if (append) "wa" else "wt"
)
}
// create a new file if current is not found,
// as we know it is new only write access is needed
createUri()?.let {
return contentResolver.openOutputStream(
it,
"w"
)
}
return null
} catch (t: Throwable) {
return null
}
}
override fun openInputStream(): InputStream? {
try {
return contentResolver.openInputStream(uri() ?: return null)
} catch (t: Throwable) {
return null
}
}
override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? {
TODO("Not yet implemented")
}
}

View file

@ -0,0 +1,244 @@
package com.lagradost.cloudstream3.utils.storage
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import com.hippo.unifile.UniFile
import com.hippo.unifile.UniRandomAccessFile
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
interface SafeFile {
companion object {
fun fromUri(context: Context, uri: Uri): SafeFile? {
return UniFileWrapper(UniFile.fromUri(context, uri) ?: return null)
}
fun fromFile(context: Context, file: File?): SafeFile? {
if (file == null) return null
// because UniFile sucks balls on Media we have to do this
val absPath = file.absolutePath.removePrefix(File.separator)
for (value in MediaFileContentType.values()) {
val prefixes = listOf(value.toAbsolutePath(), value.toPath())
for (prefix in prefixes) {
if (!absPath.startsWith(prefix)) continue
return fromMedia(
context,
value,
absPath.removePrefix(prefix).ifBlank { File.separator }
)
}
}
return UniFileWrapper(UniFile.fromFile(file) ?: return null)
}
fun fromAsset(
context: Context,
filename: String?
): SafeFile? {
return UniFileWrapper(
UniFile.fromAsset(context.assets, filename ?: return null) ?: return null
)
}
fun fromResource(
context: Context,
id: Int
): SafeFile? {
return UniFileWrapper(
UniFile.fromResource(context, id) ?: return null
)
}
fun fromMedia(
context: Context,
folderType: MediaFileContentType,
path: String = File.separator,
external: Boolean = true,
): SafeFile? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
//fromUri(context, folderType.toUri(external))?.findFile(folderType.toPath())?.gotoDirectory(path)
return MediaFile(
context = context,
folderType = folderType,
external = external,
absolutePath = path
)
} else {
fromFile(
context,
File(
(Environment.getExternalStorageDirectory().absolutePath + File.separator +
folderType.toPath() + File.separator + folderType).replace(
File.separator + File.separator,
File.separator
)
)
)
}
}
}
/*val uri: Uri? get() = getUri()
val name: String? get() = getName()
val type: String? get() = getType()
val filePath: String? get() = getFilePath()
val isFile: Boolean? get() = isFile()
val isDirectory: Boolean? get() = isDirectory()
val length: Long? get() = length()
val canRead: Boolean get() = canRead()
val canWrite: Boolean get() = canWrite()
val lastModified: Long? get() = lastModified()*/
@Throws(IOException::class)
fun isFileOrThrow(): Boolean {
return isFile() ?: throw IOException("Unable to get if file is a file or directory")
}
@Throws(IOException::class)
fun lengthOrThrow(): Long {
return length() ?: throw IOException("Unable to get file length")
}
@Throws(IOException::class)
fun isDirectoryOrThrow(): Boolean {
return isDirectory() ?: throw IOException("Unable to get if file is a directory")
}
@Throws(IOException::class)
fun filePathOrThrow(): String {
return filePath() ?: throw IOException("Unable to get file path")
}
@Throws(IOException::class)
fun uriOrThrow(): Uri {
return uri() ?: throw IOException("Unable to get uri")
}
@Throws(IOException::class)
fun renameOrThrow(name: String?) {
if (!renameTo(name)) {
throw IOException("Unable to rename to $name")
}
}
@Throws(IOException::class)
fun openOutputStreamOrThrow(append: Boolean = false): OutputStream {
return openOutputStream(append) ?: throw IOException("Unable to open output stream")
}
@Throws(IOException::class)
fun openInputStreamOrThrow(): InputStream {
return openInputStream() ?: throw IOException("Unable to open input stream")
}
@Throws(IOException::class)
fun existsOrThrow(): Boolean {
return exists() ?: throw IOException("Unable get if file exists")
}
@Throws(IOException::class)
fun findFileOrThrow(displayName: String?, ignoreCase: Boolean = false): SafeFile {
return findFile(displayName, ignoreCase) ?: throw IOException("Unable find file")
}
@Throws(IOException::class)
fun gotoDirectoryOrThrow(
directoryName: String?,
createMissingDirectories: Boolean = true
): SafeFile {
return gotoDirectory(directoryName, createMissingDirectories)
?: throw IOException("Unable to go to directory $directoryName")
}
@Throws(IOException::class)
fun listFilesOrThrow(): List<SafeFile> {
return listFiles() ?: throw IOException("Unable to get files")
}
@Throws(IOException::class)
fun createFileOrThrow(displayName: String?): SafeFile {
return createFile(displayName) ?: throw IOException("Unable to create file $displayName")
}
@Throws(IOException::class)
fun createDirectoryOrThrow(directoryName: String?): SafeFile {
return createDirectory(
directoryName ?: throw IOException("Unable to create file with invalid name")
)
?: throw IOException("Unable to create directory $directoryName")
}
@Throws(IOException::class)
fun deleteOrThrow() {
if (!delete()) {
throw IOException("Unable to delete file")
}
}
/** file.gotoDirectory("a/b/c") -> "file/a/b/c/" where a null or blank directoryName
* returns itself. createMissingDirectories specifies if the dirs should be created
* when travelling or break at a dir not found */
fun gotoDirectory(
directoryName: String?,
createMissingDirectories: Boolean = true
): SafeFile? {
if (directoryName == null) return this
return directoryName.split(File.separatorChar).filter { it.isNotBlank() }
.fold(this) { file: SafeFile?, directory ->
// as MediaFile does not actually create a directory we can do this
if (createMissingDirectories || this is MediaFile) {
file?.createDirectory(directory)
} else {
val next = file?.findFile(directory)
// we require the file to be a directory
if (next?.isDirectory() != true) {
null
} else {
next
}
}
}
}
fun createFile(displayName: String?): SafeFile?
fun createDirectory(directoryName: String?): SafeFile?
fun uri(): Uri?
fun name(): String?
fun type(): String?
fun filePath(): String?
fun isDirectory(): Boolean?
fun isFile(): Boolean?
fun lastModified(): Long?
fun length(): Long?
fun canRead(): Boolean
fun canWrite(): Boolean
fun delete(): Boolean
fun exists(): Boolean?
fun listFiles(): List<SafeFile>?
// fun listFiles(filter: FilenameFilter?): Array<File>?
fun findFile(displayName: String?, ignoreCase: Boolean = false): SafeFile?
fun renameTo(name: String?): Boolean
/** Open a stream on to the content associated with the file */
fun openOutputStream(append: Boolean = false): OutputStream?
/** Open a stream on to the content associated with the file */
fun openInputStream(): InputStream?
/** Get a random access stuff of the UniFile, "r" or "rw" */
fun createRandomAccessFile(mode: String?): UniRandomAccessFile?
}

View file

@ -0,0 +1,116 @@
package com.lagradost.cloudstream3.utils.storage
import android.net.Uri
import com.hippo.unifile.UniFile
import com.hippo.unifile.UniRandomAccessFile
import com.lagradost.cloudstream3.mvvm.logError
import okhttp3.internal.closeQuietly
import java.io.InputStream
import java.io.OutputStream
private fun UniFile.toFile(): SafeFile {
return UniFileWrapper(this)
}
fun <T> safe(apiCall: () -> T): T? {
return try {
apiCall.invoke()
} catch (throwable: Throwable) {
logError(throwable)
null
}
}
class UniFileWrapper(val file: UniFile) : SafeFile {
override fun createFile(displayName: String?): SafeFile? {
return file.createFile(displayName)?.toFile()
}
override fun createDirectory(directoryName: String?): SafeFile? {
return file.createDirectory(directoryName)?.toFile()
}
override fun uri(): Uri? {
return safe { file.uri }
}
override fun name(): String? {
return safe { file.name }
}
override fun type(): String? {
return safe { file.type }
}
override fun filePath(): String? {
return safe { file.filePath }
}
override fun isDirectory(): Boolean? {
return safe { file.isDirectory }
}
override fun isFile(): Boolean? {
return safe { file.isFile }
}
override fun lastModified(): Long? {
return safe { file.lastModified() }
}
override fun length(): Long? {
return safe {
val len = file.length()
if (len <= 1) {
val inputStream = this.openInputStream() ?: return@safe null
try {
inputStream.available().toLong()
} finally {
inputStream.closeQuietly()
}
} else {
len
}
}
}
override fun canRead(): Boolean {
return safe { file.canRead() } ?: false
}
override fun canWrite(): Boolean {
return safe { file.canWrite() } ?: false
}
override fun delete(): Boolean {
return safe { file.delete() } ?: false
}
override fun exists(): Boolean? {
return safe { file.exists() }
}
override fun listFiles(): List<SafeFile>? {
return safe { file.listFiles()?.mapNotNull { it?.toFile() } }
}
override fun findFile(displayName: String?, ignoreCase: Boolean): SafeFile? {
return safe { file.findFile(displayName, ignoreCase)?.toFile() }
}
override fun renameTo(name: String?): Boolean {
return safe { file.renameTo(name) } ?: return false
}
override fun openOutputStream(append: Boolean): OutputStream? {
return safe { file.openOutputStream(append) }
}
override fun openInputStream(): InputStream? {
return safe { file.openInputStream() }
}
override fun createRandomAccessFile(mode: String?): UniRandomAccessFile? {
return safe { file.createRandomAccessFile(mode) }
}
}