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.view.KeyEvent
import androidx.appcompat.app.AppCompatActivity
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.CommonActivity
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.utils.ExtractorUri
import com.lagradost.cloudstream3.utils.UIHelper.navigate
import com.lagradost.cloudstream3.utils.storage.SafeFile
const val DTAG = "PlayerActivity"
@ -50,7 +50,7 @@ class DownloadedPlayerActivity : AppCompatActivity() {
}
private fun playUri(uri: Uri) {
val name = UniFile.fromUri(this, uri).name
val name = SafeFile.fromUri(this, uri)?.name()
this.navigate(
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
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.popCurrentPage
import com.lagradost.cloudstream3.utils.UIHelper.toPx
import com.lagradost.cloudstream3.utils.storage.SafeFile
import kotlinx.coroutines.Job
import java.util.*
import kotlin.math.abs
@ -525,10 +526,11 @@ class GeneratorPlayer : FullScreenPlayer() {
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
val file = UniFile.fromUri(ctx, uri)
println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}")
val file = SafeFile.fromUri(ctx, uri)
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
val name = file.name ?: uri.toString()
val name = fileName ?: uri.toString()
val subtitleData = SubtitleData(
name,

View file

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

View file

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

View file

@ -8,7 +8,6 @@ import android.content.*
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.os.Environment
import androidx.annotation.DrawableRes
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -20,7 +19,6 @@ import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.bumptech.glide.load.model.GlideUrl
import com.fasterxml.jackson.annotation.JsonProperty
import com.hippo.unifile.UniFile
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
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.mvvm.logError
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
import com.lagradost.cloudstream3.services.VideoDownloadService
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
import com.lagradost.cloudstream3.utils.Coroutines.main
import com.lagradost.cloudstream3.utils.DataStore.getKey
import com.lagradost.cloudstream3.utils.DataStore.removeKey
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@ -160,24 +158,33 @@ object VideoDownloadManager {
@JsonProperty("pkg") val pkg: DownloadResumePackage,
)
private const val SUCCESS_DOWNLOAD_DONE = 1
private const val SUCCESS_STREAM = 3
private const val SUCCESS_STOPPED = 2
data class DownloadStatus(
/** if you should retry with the same args and hope for a better result */
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
private const val ERROR_DELETING_FILE = 3
private const val ERROR_CREATE_FILE = -2
private const val ERROR_UNKNOWN = -10
/** Invalid input, just skip to the next one as the same args will give the same error */
private val DOWNLOAD_INVALID_INPUT =
DownloadStatus(retrySame = false, tryNext = true, success = false)
//private const val ERROR_OPEN_FILE = -3
private const val ERROR_TOO_SMALL_CONNECTION = -4
/** no need to try any other mirror as we have downloaded the file */
private val DOWNLOAD_SUCCESS =
DownloadStatus(retrySame = false, tryNext = false, success = true)
//private const val ERROR_WRONG_CONTENT = -5
private const val ERROR_CONNECTION_ERROR = -6
/** the user pressed stop, so no need to download anything else */
private val DOWNLOAD_STOPPED =
DownloadStatus(retrySame = false, tryNext = false, success = true)
//private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7
//private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8
private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9
/** the process failed due to some reason, so we retry and also try the next mirror */
private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false)
/** 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"
const val KEY_DOWNLOAD_INFO = "download_info"
@ -209,15 +216,15 @@ object VideoDownloadManager {
}
}
/** Will return IsDone if not found or error */
fun getDownloadState(id: Int): DownloadType {
return try {
downloadStatus[id] ?: DownloadType.IsDone
} catch (e: Exception) {
logError(e)
DownloadType.IsDone
}
}
///** Will return IsDone if not found or error */
//fun getDownloadState(id: Int): DownloadType {
// return try {
// downloadStatus[id] ?: DownloadType.IsDone
// } catch (e: Exception) {
// logError(e)
// DownloadType.IsDone
// }
//}
private val cachedBitmaps = hashMapOf<String, 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) {
builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false)
} 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 ""
@ -496,10 +503,11 @@ object VideoDownloadManager {
basePath: String?
): List<Pair<String, Uri>>? {
val base = basePathToFile(context, basePath)
val folder = base?.gotoDir(relativePath, false) ?: return null
if (!folder.isDirectory) return null
val folder = base?.gotoDirectory(relativePath, false) ?: 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(
private val fileLength: Long,
val file: UniFile,
val file: SafeFile,
//val fileStream: OutputStream,
) {
fun open() : OutputStream {
return file.openOutputStream(resume)
@Throws(IOException::class)
fun open(): OutputStream {
return file.openOutputStreamOrThrow(resume)
}
fun openNew() : OutputStream {
return file.openOutputStream(false)
@Throws(IOException::class)
fun openNew(): OutputStream {
return file.openOutputStreamOrThrow(false)
}
fun delete(): Boolean {
return file.delete()
}
val resume: Boolean get() = fileLength > 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)
fun setupStream(
context: Context,
@ -552,19 +552,39 @@ object VideoDownloadManager {
folder: String?,
extension: String,
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 {
val displayName = getDisplayName(name, extension)
val (baseFile, _) = context.getBasePath()
val subDir = baseFile?.gotoDir(folder) ?: throw IOException()
val subDir = baseFile.gotoDirectoryOrThrow(folder)
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
} else {
if (tryResume) {
foundFile to foundFile.size()
foundFile to foundFile.lengthOrThrow()
} else {
foundFile.deleteOrThrow()
subDir.createFileOrThrow(displayName) to 0L
@ -1004,21 +1024,20 @@ object VideoDownloadManager {
}
}
@Throws
suspend fun downloadThing(
context: Context,
link: IDownloadableMinimum,
name: String,
folder: String?,
folder: String,
extension: String,
tryResume: Boolean,
parentId: Int?,
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
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
if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) {
return@withContext ERROR_UNKNOWN
if (link.url.startsWith("magnet") || link.url.endsWith(".torrent") || parallelConnections < 1) {
return@withContext DOWNLOAD_INVALID_INPUT
}
var fileStream: OutputStream? = null
@ -1033,13 +1052,10 @@ object VideoDownloadManager {
// get the file path
val (baseFile, basePath) = context.getBasePath()
val displayName = getDisplayName(name, extension)
val relativePath =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
folder
) else folder
if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
// set up the download file
val stream = setupStream(context, name, relativePath, extension, tryResume)
val stream = setupStream(baseFile, name, folder, extension, tryResume)
fileStream = stream.open()
@ -1069,7 +1085,7 @@ object VideoDownloadManager {
metadata.setDownloadFileInfoTemplate(
DownloadedFileInfo(
totalBytes = metadata.approxTotalBytes,
relativePath = relativePath ?: "",
relativePath = folder,
displayName = displayName,
basePath = basePath
)
@ -1202,19 +1218,19 @@ object VideoDownloadManager {
if (!stream.exists) metadata.type = DownloadType.IsStopped
if (metadata.type == DownloadType.IsFailed) {
return@withContext ERROR_CONNECTION_ERROR
return@withContext DOWNLOAD_FAILED
}
if (metadata.type == DownloadType.IsStopped) {
// we need to close before delete
fileStream.closeQuietly()
metadata.onDelete()
deleteFile(context, baseFile, relativePath ?: "", displayName)
return@withContext SUCCESS_STOPPED
stream.delete()
return@withContext DOWNLOAD_STOPPED
}
metadata.type = DownloadType.IsDone
return@withContext SUCCESS_DOWNLOAD_DONE
return@withContext DOWNLOAD_SUCCESS
} catch (e: IOException) {
// some sort of IO error, this should not happened
// we just rethrow it
@ -1226,7 +1242,7 @@ object VideoDownloadManager {
// note that when failing we don't want to delete the file,
// only user interaction has that power
metadata.type = DownloadType.IsFailed
return@withContext ERROR_CONNECTION_ERROR
return@withContext DOWNLOAD_FAILED
} finally {
fileStream?.closeQuietly()
//requestStream?.closeQuietly()
@ -1234,39 +1250,36 @@ object VideoDownloadManager {
}
}
@Throws
private suspend fun downloadHLS(
context: Context,
link: ExtractorLink,
name: String,
folder: String?,
folder: String,
parentId: Int?,
startIndex: Int?,
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
parallelConnections: Int = 3
): Int = withContext(Dispatchers.IO) {
require(parallelConnections >= 1)
): DownloadStatus = withContext(Dispatchers.IO) {
if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT
val metadata = DownloadMetaData(
createNotificationCallback = createNotificationCallback,
id = parentId
)
val extension = "mp4"
var fileStream: OutputStream? = null
try {
val extension = "mp4"
// the start .ts index
var startAt = startIndex ?: 0
// set up the file data
val (baseFile, basePath) = context.getBasePath()
val relativePath =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
folder
) else folder
if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
val displayName = getDisplayName(name, extension)
val stream =
setupStream(context, name, relativePath, extension, startAt > 0)
setupStream(baseFile, name, folder, extension, startAt > 0)
if (!stream.resume) startAt = 0
fileStream = stream.open()
@ -1277,7 +1290,7 @@ object VideoDownloadManager {
metadata.setDownloadFileInfoTemplate(
DownloadedFileInfo(
totalBytes = 0,
relativePath = relativePath ?: "",
relativePath = folder,
displayName = displayName,
basePath = basePath
)
@ -1406,99 +1419,29 @@ object VideoDownloadManager {
if (!stream.exists) metadata.type = DownloadType.IsStopped
if (metadata.type == DownloadType.IsFailed) {
return@withContext ERROR_CONNECTION_ERROR
return@withContext DOWNLOAD_FAILED
}
if (metadata.type == DownloadType.IsStopped) {
// we need to close before delete
fileStream.closeQuietly()
metadata.onDelete()
deleteFile(context, baseFile, relativePath ?: "", displayName)
return@withContext SUCCESS_STOPPED
stream.delete()
return@withContext DOWNLOAD_STOPPED
}
metadata.type = DownloadType.IsDone
return@withContext SUCCESS_DOWNLOAD_DONE
return@withContext DOWNLOAD_SUCCESS
} catch (t: Throwable) {
logError(t)
metadata.type = DownloadType.IsFailed
return@withContext ERROR_UNKNOWN
return@withContext DOWNLOAD_FAILED
} finally {
fileStream?.closeQuietly()
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 {
return "$name.$extension"
}
@ -1510,33 +1453,22 @@ object VideoDownloadManager {
* 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? {
fun getDefaultDir(context: Context): SafeFile? {
// See https://www.py4u.net/discuss/614761
return UniFile.fromFile(
File(
Environment.getExternalStorageDirectory().absolutePath + File.separatorChar +
Environment.DIRECTORY_DOWNLOADS
)
return SafeFile.fromMedia(
context, MediaFileContentType.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.
* 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 {
path.isNullOrBlank() -> getDownloadDir()
path.startsWith("content://") -> UniFile.fromUri(context, path.toUri())
else -> UniFile.fromFile(File(path))
path.isNullOrBlank() -> getDefaultDir(context)
path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri())
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.
* 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 basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null)
return basePathToFile(this, basePathSetting) to basePathSetting
}
fun UniFile?.isDownloadDir(): Boolean {
return this != null && this.filePath == getDownloadDir()?.filePath
}
fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String {
return getFileName(context, metadata.name, metadata.episode, metadata.season)
}
@ -1596,7 +1523,7 @@ object VideoDownloadManager {
link: ExtractorLink,
notificationCallback: (Int, Notification) -> Unit,
tryResume: Boolean = false,
): Int {
): DownloadStatus {
val name = getFileName(context, ep)
// Make sure this is cancelled when download is done or cancelled.
@ -1638,7 +1565,7 @@ object VideoDownloadManager {
context,
link,
name,
folder,
folder ?: "",
ep.id,
startIndex,
callback
@ -1648,7 +1575,7 @@ object VideoDownloadManager {
context,
link,
name,
folder,
folder ?: "",
"mp4",
tryResume,
ep.id,
@ -1656,7 +1583,7 @@ object VideoDownloadManager {
)
}
} catch (t: Throwable) {
return ERROR_UNKNOWN
return DOWNLOAD_FAILED
} finally {
extractorJob.cancel()
}
@ -1698,10 +1625,8 @@ object VideoDownloadManager {
notificationCallback,
resume
)
//.also { println("Single episode finished with return code: $it") }
// retry every link at least once
if (connectionResult <= 0) {
if (connectionResult.retrySame) {
connectionResult = downloadSingleEpisode(
context,
item.source,
@ -1713,11 +1638,12 @@ object VideoDownloadManager {
)
}
if (connectionResult > 0) { // SUCCESS
if (connectionResult.success) { // SUCCESS
removeKey(KEY_RESUME_PACKAGES, id.toString())
break
} else if (index == item.links.lastIndex) {
} else if (!connectionResult.tryNext || index >= item.links.lastIndex) {
downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed))
break
}
}
} catch (e: Exception) {
@ -1731,62 +1657,69 @@ object VideoDownloadManager {
// return id
}
fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
val res = getDownloadFileInfo(context, id)
if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return res
/* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
val res = getDownloadFileInfo(context, id)
if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
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 {
val info =
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return null
val base = basePathToFile(context, info.basePath)
val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName)
if (file?.exists() != true) return null
val file = info.toFile(context)
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) {
logError(e)
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 {
val success = deleteFile(context, id)
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
return success
}
private fun deleteFile(
/*private fun deleteFile(
context: Context,
folder: UniFile?,
folder: SafeFile?,
relativePath: String,
displayName: String
): Boolean {
val file = folder?.gotoDir(relativePath)?.findFile(displayName) ?: return false
if (!file.exists()) return true
val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false
if (file.exists() == false) return true
return try {
file.delete()
} catch (e: Exception) {
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 {
val info =
@ -1795,8 +1728,7 @@ object VideoDownloadManager {
downloadProgressEvent.invoke(Triple(id, 0, 0))
downloadStatusEvent.invoke(id to DownloadType.IsStopped)
downloadDeleteEvent.invoke(id)
val base = basePathToFile(context, info.basePath)
return deleteFile(context, base, info.relativePath, info.displayName)
return info.toFile(context)?.delete() ?: false
}
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) }
}
}