mirror of
https://github.com/recloudstream/cloudstream.git
synced 2024-08-15 01:53:11 +00:00
fixed resume download + migrated filesystem to SafeFile
This commit is contained in:
parent
afcbdeecc8
commit
3ea6b1a8d5
8 changed files with 924 additions and 260 deletions
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue