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.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import com.hippo.unifile.UniFile
|
|
||||||
import com.lagradost.cloudstream3.CommonActivity
|
import com.lagradost.cloudstream3.CommonActivity
|
||||||
import com.lagradost.cloudstream3.R
|
import com.lagradost.cloudstream3.R
|
||||||
import com.lagradost.cloudstream3.utils.ExtractorUri
|
import com.lagradost.cloudstream3.utils.ExtractorUri
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||||
|
import com.lagradost.cloudstream3.utils.storage.SafeFile
|
||||||
|
|
||||||
const val DTAG = "PlayerActivity"
|
const val DTAG = "PlayerActivity"
|
||||||
|
|
||||||
|
@ -50,7 +50,7 @@ class DownloadedPlayerActivity : AppCompatActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playUri(uri: Uri) {
|
private fun playUri(uri: Uri) {
|
||||||
val name = UniFile.fromUri(this, uri).name
|
val name = SafeFile.fromUri(this, uri)?.name()
|
||||||
this.navigate(
|
this.navigate(
|
||||||
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
R.id.global_to_navigation_player, GeneratorPlayer.newInstance(
|
||||||
DownloadFileGenerator(
|
DownloadFileGenerator(
|
||||||
|
|
|
@ -52,6 +52,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
|
import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
import com.lagradost.cloudstream3.utils.UIHelper.toPx
|
||||||
|
import com.lagradost.cloudstream3.utils.storage.SafeFile
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
@ -525,10 +526,11 @@ class GeneratorPlayer : FullScreenPlayer() {
|
||||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
)
|
)
|
||||||
|
|
||||||
val file = UniFile.fromUri(ctx, uri)
|
val file = SafeFile.fromUri(ctx, uri)
|
||||||
println("Loaded subtitle file. Selected URI path: $uri - Name: ${file.name}")
|
val fileName = file?.name()
|
||||||
|
println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName")
|
||||||
// DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES
|
// DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES
|
||||||
val name = file.name ?: uri.toString()
|
val name = fileName ?: uri.toString()
|
||||||
|
|
||||||
val subtitleData = SubtitleData(
|
val subtitleData = SubtitleData(
|
||||||
name,
|
name,
|
||||||
|
|
|
@ -3,9 +3,7 @@ package com.lagradost.cloudstream3.ui.settings
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
@ -13,7 +11,6 @@ import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
import androidx.preference.PreferenceFragmentCompat
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.hippo.unifile.UniFile
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.allProviders
|
import com.lagradost.cloudstream3.APIHolder.allProviders
|
||||||
import com.lagradost.cloudstream3.AcraApplication
|
import com.lagradost.cloudstream3.AcraApplication
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
|
||||||
|
@ -41,7 +38,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||||
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
import com.lagradost.cloudstream3.utils.USER_PROVIDER_API
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath
|
||||||
import java.io.File
|
import com.lagradost.cloudstream3.utils.storage.SafeFile
|
||||||
|
|
||||||
fun getCurrentLocale(context: Context): String {
|
fun getCurrentLocale(context: Context): String {
|
||||||
val res = context.resources
|
val res = context.resources
|
||||||
|
@ -139,8 +136,9 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
context.contentResolver.takePersistableUriPermission(uri, flags)
|
context.contentResolver.takePersistableUriPermission(uri, flags)
|
||||||
|
|
||||||
val file = UniFile.fromUri(context, uri)
|
val file = SafeFile.fromUri(context, uri)
|
||||||
println("Selected URI path: $uri - Full path: ${file.filePath}")
|
val filePath = file?.filePath()
|
||||||
|
println("Selected URI path: $uri - Full path: $filePath")
|
||||||
|
|
||||||
// Stores the real URI using download_path_key
|
// Stores the real URI using download_path_key
|
||||||
// Important that the URI is stored instead of filepath due to permissions.
|
// Important that the URI is stored instead of filepath due to permissions.
|
||||||
|
@ -149,7 +147,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
// From URI -> File path
|
// From URI -> File path
|
||||||
// File path here is purely for cosmetic purposes in settings
|
// File path here is purely for cosmetic purposes in settings
|
||||||
(file.filePath ?: uri.toString()).let {
|
(filePath ?: uri.toString()).let {
|
||||||
PreferenceManager.getDefaultSharedPreferences(context)
|
PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
.edit().putString(getString(R.string.download_path_pref), it).apply()
|
.edit().putString(getString(R.string.download_path_pref), it).apply()
|
||||||
}
|
}
|
||||||
|
@ -306,25 +304,23 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
||||||
}
|
}
|
||||||
return@setOnPreferenceClickListener true
|
return@setOnPreferenceClickListener true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadDirs(): List<String> {
|
fun getDownloadDirs(): List<String> {
|
||||||
return normalSafeApiCall {
|
return normalSafeApiCall {
|
||||||
val defaultDir = VideoDownloadManager.getDownloadDir()?.filePath
|
context?.let { ctx ->
|
||||||
|
val defaultDir = VideoDownloadManager.getDefaultDir(ctx)?.filePath()
|
||||||
|
|
||||||
// app_name_download_path = Cloudstream and does not change depending on release.
|
val first = listOf(defaultDir)
|
||||||
// DOES NOT WORK ON SCOPED STORAGE.
|
(try {
|
||||||
val secondaryDir =
|
val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second }
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) null else Environment.getExternalStorageDirectory().absolutePath +
|
|
||||||
File.separator + resources.getString(R.string.app_name_download_path)
|
|
||||||
val first = listOf(defaultDir, secondaryDir)
|
|
||||||
(try {
|
|
||||||
val currentDir = context?.getBasePath()?.let { it.first?.filePath ?: it.second }
|
|
||||||
|
|
||||||
(first +
|
(first +
|
||||||
requireContext().getExternalFilesDirs("").mapNotNull { it.path } +
|
ctx.getExternalFilesDirs("").mapNotNull { it.path } +
|
||||||
currentDir)
|
currentDir)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
first
|
first
|
||||||
}).filterNotNull().distinct()
|
}).filterNotNull().distinct()
|
||||||
|
}
|
||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -339,7 +335,7 @@ class SettingsGeneral : PreferenceFragmentCompat() {
|
||||||
|
|
||||||
val currentDir =
|
val currentDir =
|
||||||
settingsManager.getString(getString(R.string.download_path_pref), null)
|
settingsManager.getString(getString(R.string.download_path_pref), null)
|
||||||
?: VideoDownloadManager.getDownloadDir().toString()
|
?: context?.let { ctx -> VideoDownloadManager.getDefaultDir(ctx).toString() }
|
||||||
|
|
||||||
activity?.showBottomDialog(
|
activity?.showBottomDialog(
|
||||||
dirs + listOf("Custom"),
|
dirs + listOf("Custom"),
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
package com.lagradost.cloudstream3.utils
|
package com.lagradost.cloudstream3.utils
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
|
||||||
import android.provider.MediaStore
|
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
@ -36,9 +33,9 @@ import com.lagradost.cloudstream3.utils.DataStore.mapper
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw
|
import com.lagradost.cloudstream3.utils.DataStore.setKeyRaw
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
import com.lagradost.cloudstream3.utils.UIHelper.checkWrite
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
import com.lagradost.cloudstream3.utils.UIHelper.requestRW
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath
|
import com.lagradost.cloudstream3.utils.VideoDownloadManager.setupStream
|
||||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.isDownloadDir
|
import okhttp3.internal.closeQuietly
|
||||||
import java.io.IOException
|
import java.io.OutputStream
|
||||||
import java.io.PrintWriter
|
import java.io.PrintWriter
|
||||||
import java.lang.System.currentTimeMillis
|
import java.lang.System.currentTimeMillis
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
@ -147,6 +144,8 @@ object BackupUtils {
|
||||||
|
|
||||||
@SuppressLint("SimpleDateFormat")
|
@SuppressLint("SimpleDateFormat")
|
||||||
fun FragmentActivity.backup() {
|
fun FragmentActivity.backup() {
|
||||||
|
var fileStream: OutputStream? = null
|
||||||
|
var printStream: PrintWriter? = null
|
||||||
try {
|
try {
|
||||||
if (!checkWrite()) {
|
if (!checkWrite()) {
|
||||||
showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG)
|
showToast(getString(R.string.backup_failed), Toast.LENGTH_LONG)
|
||||||
|
@ -154,13 +153,16 @@ object BackupUtils {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val subDir = getBasePath().first
|
|
||||||
val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis()))
|
val date = SimpleDateFormat("yyyy_MM_dd_HH_mm").format(Date(currentTimeMillis()))
|
||||||
val ext = "json"
|
val ext = "json"
|
||||||
val displayName = "CS3_Backup_${date}"
|
val displayName = "CS3_Backup_${date}"
|
||||||
val backupFile = getBackup()
|
val backupFile = getBackup()
|
||||||
|
val stream = setupStream(this, displayName, null, ext, false)
|
||||||
|
fileStream = stream.openNew()
|
||||||
|
printStream = PrintWriter(fileStream)
|
||||||
|
printStream.print(mapper.writeValueAsString(backupFile))
|
||||||
|
|
||||||
val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
/*val steam = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||||
&& subDir?.isDownloadDir() == true
|
&& subDir?.isDownloadDir() == true
|
||||||
) {
|
) {
|
||||||
val cr = this.contentResolver
|
val cr = this.contentResolver
|
||||||
|
@ -198,7 +200,7 @@ object BackupUtils {
|
||||||
|
|
||||||
val printStream = PrintWriter(steam)
|
val printStream = PrintWriter(steam)
|
||||||
printStream.print(mapper.writeValueAsString(backupFile))
|
printStream.print(mapper.writeValueAsString(backupFile))
|
||||||
printStream.close()
|
printStream.close()*/
|
||||||
|
|
||||||
showToast(
|
showToast(
|
||||||
R.string.backup_success,
|
R.string.backup_success,
|
||||||
|
@ -214,6 +216,9 @@ object BackupUtils {
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
printStream?.closeQuietly()
|
||||||
|
fileStream?.closeQuietly()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ import android.content.*
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
@ -20,7 +19,6 @@ import androidx.work.OneTimeWorkRequest
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.bumptech.glide.load.model.GlideUrl
|
import com.bumptech.glide.load.model.GlideUrl
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
import com.fasterxml.jackson.annotation.JsonProperty
|
||||||
import com.hippo.unifile.UniFile
|
|
||||||
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.removeKey
|
||||||
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
|
||||||
|
@ -31,19 +29,19 @@ import com.lagradost.cloudstream3.TvType
|
||||||
import com.lagradost.cloudstream3.app
|
import com.lagradost.cloudstream3.app
|
||||||
import com.lagradost.cloudstream3.mvvm.logError
|
import com.lagradost.cloudstream3.mvvm.logError
|
||||||
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
import com.lagradost.cloudstream3.mvvm.normalSafeApiCall
|
||||||
import com.lagradost.cloudstream3.mvvm.suspendSafeApiCall
|
|
||||||
import com.lagradost.cloudstream3.services.VideoDownloadService
|
import com.lagradost.cloudstream3.services.VideoDownloadService
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||||
import com.lagradost.cloudstream3.utils.Coroutines.main
|
import com.lagradost.cloudstream3.utils.Coroutines.main
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||||
import com.lagradost.cloudstream3.utils.DataStore.removeKey
|
import com.lagradost.cloudstream3.utils.DataStore.removeKey
|
||||||
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute
|
||||||
|
import com.lagradost.cloudstream3.utils.storage.MediaFileContentType
|
||||||
|
import com.lagradost.cloudstream3.utils.storage.SafeFile
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
@ -160,24 +158,33 @@ object VideoDownloadManager {
|
||||||
@JsonProperty("pkg") val pkg: DownloadResumePackage,
|
@JsonProperty("pkg") val pkg: DownloadResumePackage,
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val SUCCESS_DOWNLOAD_DONE = 1
|
data class DownloadStatus(
|
||||||
private const val SUCCESS_STREAM = 3
|
/** if you should retry with the same args and hope for a better result */
|
||||||
private const val SUCCESS_STOPPED = 2
|
val retrySame: Boolean,
|
||||||
|
/** if you should try the next mirror */
|
||||||
|
val tryNext: Boolean,
|
||||||
|
/** if the result is what the user intended */
|
||||||
|
val success: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
// will not download the next one, but is still classified as an error
|
/** Invalid input, just skip to the next one as the same args will give the same error */
|
||||||
private const val ERROR_DELETING_FILE = 3
|
private val DOWNLOAD_INVALID_INPUT =
|
||||||
private const val ERROR_CREATE_FILE = -2
|
DownloadStatus(retrySame = false, tryNext = true, success = false)
|
||||||
private const val ERROR_UNKNOWN = -10
|
|
||||||
|
|
||||||
//private const val ERROR_OPEN_FILE = -3
|
/** no need to try any other mirror as we have downloaded the file */
|
||||||
private const val ERROR_TOO_SMALL_CONNECTION = -4
|
private val DOWNLOAD_SUCCESS =
|
||||||
|
DownloadStatus(retrySame = false, tryNext = false, success = true)
|
||||||
|
|
||||||
//private const val ERROR_WRONG_CONTENT = -5
|
/** the user pressed stop, so no need to download anything else */
|
||||||
private const val ERROR_CONNECTION_ERROR = -6
|
private val DOWNLOAD_STOPPED =
|
||||||
|
DownloadStatus(retrySame = false, tryNext = false, success = true)
|
||||||
|
|
||||||
//private const val ERROR_MEDIA_STORE_URI_CANT_BE_CREATED = -7
|
/** the process failed due to some reason, so we retry and also try the next mirror */
|
||||||
//private const val ERROR_CONTENT_RESOLVER_CANT_OPEN_STREAM = -8
|
private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false)
|
||||||
private const val ERROR_CONTENT_RESOLVER_NOT_FOUND = -9
|
|
||||||
|
/** bad config, skip all mirrors as every call to download will have the same bad config */
|
||||||
|
private val DOWNLOAD_BAD_CONFIG =
|
||||||
|
DownloadStatus(retrySame = false, tryNext = false, success = false)
|
||||||
|
|
||||||
private const val KEY_RESUME_PACKAGES = "download_resume"
|
private const val KEY_RESUME_PACKAGES = "download_resume"
|
||||||
const val KEY_DOWNLOAD_INFO = "download_info"
|
const val KEY_DOWNLOAD_INFO = "download_info"
|
||||||
|
@ -209,15 +216,15 @@ object VideoDownloadManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Will return IsDone if not found or error */
|
///** Will return IsDone if not found or error */
|
||||||
fun getDownloadState(id: Int): DownloadType {
|
//fun getDownloadState(id: Int): DownloadType {
|
||||||
return try {
|
// return try {
|
||||||
downloadStatus[id] ?: DownloadType.IsDone
|
// downloadStatus[id] ?: DownloadType.IsDone
|
||||||
} catch (e: Exception) {
|
// } catch (e: Exception) {
|
||||||
logError(e)
|
// logError(e)
|
||||||
DownloadType.IsDone
|
// DownloadType.IsDone
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
private val cachedBitmaps = hashMapOf<String, Bitmap>()
|
private val cachedBitmaps = hashMapOf<String, Bitmap>()
|
||||||
fun Context.getImageBitmapFromUrl(url: String, headers: Map<String, String>? = null): Bitmap? {
|
fun Context.getImageBitmapFromUrl(url: String, headers: Map<String, String>? = null): Bitmap? {
|
||||||
|
@ -302,7 +309,7 @@ object VideoDownloadManager {
|
||||||
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
|
if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) {
|
||||||
builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false)
|
builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false)
|
||||||
} else if (state == DownloadType.IsPending) {
|
} else if (state == DownloadType.IsPending) {
|
||||||
builder.setProgress(0,0,true)
|
builder.setProgress(0, 0, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else ""
|
val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else ""
|
||||||
|
@ -496,10 +503,11 @@ object VideoDownloadManager {
|
||||||
basePath: String?
|
basePath: String?
|
||||||
): List<Pair<String, Uri>>? {
|
): List<Pair<String, Uri>>? {
|
||||||
val base = basePathToFile(context, basePath)
|
val base = basePathToFile(context, basePath)
|
||||||
val folder = base?.gotoDir(relativePath, false) ?: return null
|
val folder = base?.gotoDirectory(relativePath, false) ?: return null
|
||||||
if (!folder.isDirectory) return null
|
if (folder.isDirectory() != false) return null
|
||||||
|
|
||||||
return folder.listFiles()?.map { (it.name ?: "") to it.uri }
|
return folder.listFiles()
|
||||||
|
?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -514,37 +522,29 @@ object VideoDownloadManager {
|
||||||
|
|
||||||
data class StreamData(
|
data class StreamData(
|
||||||
private val fileLength: Long,
|
private val fileLength: Long,
|
||||||
val file: UniFile,
|
val file: SafeFile,
|
||||||
//val fileStream: OutputStream,
|
//val fileStream: OutputStream,
|
||||||
) {
|
) {
|
||||||
fun open() : OutputStream {
|
@Throws(IOException::class)
|
||||||
return file.openOutputStream(resume)
|
fun open(): OutputStream {
|
||||||
|
return file.openOutputStreamOrThrow(resume)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openNew() : OutputStream {
|
@Throws(IOException::class)
|
||||||
return file.openOutputStream(false)
|
fun openNew(): OutputStream {
|
||||||
|
return file.openOutputStreamOrThrow(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(): Boolean {
|
||||||
|
return file.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
val resume: Boolean get() = fileLength > 0L
|
val resume: Boolean get() = fileLength > 0L
|
||||||
val startAt: Long get() = if (resume) fileLength else 0L
|
val startAt: Long get() = if (resume) fileLength else 0L
|
||||||
val exists: Boolean get() = file.exists()
|
val exists: Boolean get() = file.exists() == true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
//class ADownloadException(val id: Int) : RuntimeException(message = "Download error $id")
|
|
||||||
|
|
||||||
fun UniFile.createFileOrThrow(displayName: String): UniFile {
|
|
||||||
return this.createFile(displayName) ?: throw IOException("Could not create file")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun UniFile.deleteOrThrow() {
|
|
||||||
if (!this.delete()) throw IOException("Could not delete file")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up the appropriate file and creates a data stream from the file.
|
|
||||||
* Used for initializing downloads.
|
|
||||||
* */
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun setupStream(
|
fun setupStream(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
@ -552,19 +552,39 @@ object VideoDownloadManager {
|
||||||
folder: String?,
|
folder: String?,
|
||||||
extension: String,
|
extension: String,
|
||||||
tryResume: Boolean,
|
tryResume: Boolean,
|
||||||
|
): StreamData {
|
||||||
|
val (base, _) = context.getBasePath()
|
||||||
|
return setupStream(
|
||||||
|
base ?: throw IOException("Bad config"),
|
||||||
|
name,
|
||||||
|
folder,
|
||||||
|
extension,
|
||||||
|
tryResume
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets up the appropriate file and creates a data stream from the file.
|
||||||
|
* Used for initializing downloads.
|
||||||
|
* */
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun setupStream(
|
||||||
|
baseFile: SafeFile,
|
||||||
|
name: String,
|
||||||
|
folder: String?,
|
||||||
|
extension: String,
|
||||||
|
tryResume: Boolean,
|
||||||
): StreamData {
|
): StreamData {
|
||||||
val displayName = getDisplayName(name, extension)
|
val displayName = getDisplayName(name, extension)
|
||||||
|
|
||||||
val (baseFile, _) = context.getBasePath()
|
val subDir = baseFile.gotoDirectoryOrThrow(folder)
|
||||||
|
|
||||||
val subDir = baseFile?.gotoDir(folder) ?: throw IOException()
|
|
||||||
val foundFile = subDir.findFile(displayName)
|
val foundFile = subDir.findFile(displayName)
|
||||||
|
|
||||||
val (file, fileLength) = if (foundFile == null || !foundFile.exists()) {
|
val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) {
|
||||||
subDir.createFileOrThrow(displayName) to 0L
|
subDir.createFileOrThrow(displayName) to 0L
|
||||||
} else {
|
} else {
|
||||||
if (tryResume) {
|
if (tryResume) {
|
||||||
foundFile to foundFile.size()
|
foundFile to foundFile.lengthOrThrow()
|
||||||
} else {
|
} else {
|
||||||
foundFile.deleteOrThrow()
|
foundFile.deleteOrThrow()
|
||||||
subDir.createFileOrThrow(displayName) to 0L
|
subDir.createFileOrThrow(displayName) to 0L
|
||||||
|
@ -1004,21 +1024,20 @@ object VideoDownloadManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws
|
|
||||||
suspend fun downloadThing(
|
suspend fun downloadThing(
|
||||||
context: Context,
|
context: Context,
|
||||||
link: IDownloadableMinimum,
|
link: IDownloadableMinimum,
|
||||||
name: String,
|
name: String,
|
||||||
folder: String?,
|
folder: String,
|
||||||
extension: String,
|
extension: String,
|
||||||
tryResume: Boolean,
|
tryResume: Boolean,
|
||||||
parentId: Int?,
|
parentId: Int?,
|
||||||
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
|
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
|
||||||
parallelConnections: Int = 3
|
parallelConnections: Int = 3
|
||||||
): Int = withContext(Dispatchers.IO) {
|
): DownloadStatus = withContext(Dispatchers.IO) {
|
||||||
// we cant download torrents with this implementation, aria2c might be used in the future
|
// we cant download torrents with this implementation, aria2c might be used in the future
|
||||||
if (link.url.startsWith("magnet") || link.url.endsWith(".torrent")) {
|
if (link.url.startsWith("magnet") || link.url.endsWith(".torrent") || parallelConnections < 1) {
|
||||||
return@withContext ERROR_UNKNOWN
|
return@withContext DOWNLOAD_INVALID_INPUT
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileStream: OutputStream? = null
|
var fileStream: OutputStream? = null
|
||||||
|
@ -1033,13 +1052,10 @@ object VideoDownloadManager {
|
||||||
// get the file path
|
// get the file path
|
||||||
val (baseFile, basePath) = context.getBasePath()
|
val (baseFile, basePath) = context.getBasePath()
|
||||||
val displayName = getDisplayName(name, extension)
|
val displayName = getDisplayName(name, extension)
|
||||||
val relativePath =
|
if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
|
|
||||||
folder
|
|
||||||
) else folder
|
|
||||||
|
|
||||||
// set up the download file
|
// set up the download file
|
||||||
val stream = setupStream(context, name, relativePath, extension, tryResume)
|
val stream = setupStream(baseFile, name, folder, extension, tryResume)
|
||||||
|
|
||||||
fileStream = stream.open()
|
fileStream = stream.open()
|
||||||
|
|
||||||
|
@ -1069,7 +1085,7 @@ object VideoDownloadManager {
|
||||||
metadata.setDownloadFileInfoTemplate(
|
metadata.setDownloadFileInfoTemplate(
|
||||||
DownloadedFileInfo(
|
DownloadedFileInfo(
|
||||||
totalBytes = metadata.approxTotalBytes,
|
totalBytes = metadata.approxTotalBytes,
|
||||||
relativePath = relativePath ?: "",
|
relativePath = folder,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
basePath = basePath
|
basePath = basePath
|
||||||
)
|
)
|
||||||
|
@ -1202,19 +1218,19 @@ object VideoDownloadManager {
|
||||||
if (!stream.exists) metadata.type = DownloadType.IsStopped
|
if (!stream.exists) metadata.type = DownloadType.IsStopped
|
||||||
|
|
||||||
if (metadata.type == DownloadType.IsFailed) {
|
if (metadata.type == DownloadType.IsFailed) {
|
||||||
return@withContext ERROR_CONNECTION_ERROR
|
return@withContext DOWNLOAD_FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.type == DownloadType.IsStopped) {
|
if (metadata.type == DownloadType.IsStopped) {
|
||||||
// we need to close before delete
|
// we need to close before delete
|
||||||
fileStream.closeQuietly()
|
fileStream.closeQuietly()
|
||||||
metadata.onDelete()
|
metadata.onDelete()
|
||||||
deleteFile(context, baseFile, relativePath ?: "", displayName)
|
stream.delete()
|
||||||
return@withContext SUCCESS_STOPPED
|
return@withContext DOWNLOAD_STOPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata.type = DownloadType.IsDone
|
metadata.type = DownloadType.IsDone
|
||||||
return@withContext SUCCESS_DOWNLOAD_DONE
|
return@withContext DOWNLOAD_SUCCESS
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
// some sort of IO error, this should not happened
|
// some sort of IO error, this should not happened
|
||||||
// we just rethrow it
|
// we just rethrow it
|
||||||
|
@ -1226,7 +1242,7 @@ object VideoDownloadManager {
|
||||||
// note that when failing we don't want to delete the file,
|
// note that when failing we don't want to delete the file,
|
||||||
// only user interaction has that power
|
// only user interaction has that power
|
||||||
metadata.type = DownloadType.IsFailed
|
metadata.type = DownloadType.IsFailed
|
||||||
return@withContext ERROR_CONNECTION_ERROR
|
return@withContext DOWNLOAD_FAILED
|
||||||
} finally {
|
} finally {
|
||||||
fileStream?.closeQuietly()
|
fileStream?.closeQuietly()
|
||||||
//requestStream?.closeQuietly()
|
//requestStream?.closeQuietly()
|
||||||
|
@ -1234,39 +1250,36 @@ object VideoDownloadManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws
|
|
||||||
private suspend fun downloadHLS(
|
private suspend fun downloadHLS(
|
||||||
context: Context,
|
context: Context,
|
||||||
link: ExtractorLink,
|
link: ExtractorLink,
|
||||||
name: String,
|
name: String,
|
||||||
folder: String?,
|
folder: String,
|
||||||
parentId: Int?,
|
parentId: Int?,
|
||||||
startIndex: Int?,
|
startIndex: Int?,
|
||||||
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
|
createNotificationCallback: (CreateNotificationMetadata) -> Unit,
|
||||||
parallelConnections: Int = 3
|
parallelConnections: Int = 3
|
||||||
): Int = withContext(Dispatchers.IO) {
|
): DownloadStatus = withContext(Dispatchers.IO) {
|
||||||
require(parallelConnections >= 1)
|
if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT
|
||||||
|
|
||||||
val metadata = DownloadMetaData(
|
val metadata = DownloadMetaData(
|
||||||
createNotificationCallback = createNotificationCallback,
|
createNotificationCallback = createNotificationCallback,
|
||||||
id = parentId
|
id = parentId
|
||||||
)
|
)
|
||||||
val extension = "mp4"
|
|
||||||
|
|
||||||
var fileStream: OutputStream? = null
|
var fileStream: OutputStream? = null
|
||||||
try {
|
try {
|
||||||
|
val extension = "mp4"
|
||||||
|
|
||||||
// the start .ts index
|
// the start .ts index
|
||||||
var startAt = startIndex ?: 0
|
var startAt = startIndex ?: 0
|
||||||
|
|
||||||
// set up the file data
|
// set up the file data
|
||||||
val (baseFile, basePath) = context.getBasePath()
|
val (baseFile, basePath) = context.getBasePath()
|
||||||
val relativePath =
|
if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && baseFile.isDownloadDir()) getRelativePath(
|
|
||||||
folder
|
|
||||||
) else folder
|
|
||||||
val displayName = getDisplayName(name, extension)
|
val displayName = getDisplayName(name, extension)
|
||||||
val stream =
|
val stream =
|
||||||
setupStream(context, name, relativePath, extension, startAt > 0)
|
setupStream(baseFile, name, folder, extension, startAt > 0)
|
||||||
if (!stream.resume) startAt = 0
|
if (!stream.resume) startAt = 0
|
||||||
fileStream = stream.open()
|
fileStream = stream.open()
|
||||||
|
|
||||||
|
@ -1277,7 +1290,7 @@ object VideoDownloadManager {
|
||||||
metadata.setDownloadFileInfoTemplate(
|
metadata.setDownloadFileInfoTemplate(
|
||||||
DownloadedFileInfo(
|
DownloadedFileInfo(
|
||||||
totalBytes = 0,
|
totalBytes = 0,
|
||||||
relativePath = relativePath ?: "",
|
relativePath = folder,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
basePath = basePath
|
basePath = basePath
|
||||||
)
|
)
|
||||||
|
@ -1406,99 +1419,29 @@ object VideoDownloadManager {
|
||||||
if (!stream.exists) metadata.type = DownloadType.IsStopped
|
if (!stream.exists) metadata.type = DownloadType.IsStopped
|
||||||
|
|
||||||
if (metadata.type == DownloadType.IsFailed) {
|
if (metadata.type == DownloadType.IsFailed) {
|
||||||
return@withContext ERROR_CONNECTION_ERROR
|
return@withContext DOWNLOAD_FAILED
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.type == DownloadType.IsStopped) {
|
if (metadata.type == DownloadType.IsStopped) {
|
||||||
// we need to close before delete
|
// we need to close before delete
|
||||||
fileStream.closeQuietly()
|
fileStream.closeQuietly()
|
||||||
metadata.onDelete()
|
metadata.onDelete()
|
||||||
deleteFile(context, baseFile, relativePath ?: "", displayName)
|
stream.delete()
|
||||||
return@withContext SUCCESS_STOPPED
|
return@withContext DOWNLOAD_STOPPED
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata.type = DownloadType.IsDone
|
metadata.type = DownloadType.IsDone
|
||||||
return@withContext SUCCESS_DOWNLOAD_DONE
|
return@withContext DOWNLOAD_SUCCESS
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
logError(t)
|
logError(t)
|
||||||
metadata.type = DownloadType.IsFailed
|
metadata.type = DownloadType.IsFailed
|
||||||
return@withContext ERROR_UNKNOWN
|
return@withContext DOWNLOAD_FAILED
|
||||||
} finally {
|
} finally {
|
||||||
fileStream?.closeQuietly()
|
fileStream?.closeQuietly()
|
||||||
metadata.close()
|
metadata.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guarantees a directory is present with the dir name (if createMissingDirectories is true).
|
|
||||||
* Works recursively when '/' is present.
|
|
||||||
* Will remove any file with the dir name if present and add directory.
|
|
||||||
* Will not work if the parent directory does not exist.
|
|
||||||
*
|
|
||||||
* @param directoryName if null will use the current path.
|
|
||||||
* @return UniFile / null if createMissingDirectories = false and folder is not found.
|
|
||||||
* */
|
|
||||||
private fun UniFile.gotoDir(
|
|
||||||
directoryName: String?,
|
|
||||||
createMissingDirectories: Boolean = true
|
|
||||||
): UniFile? {
|
|
||||||
if(directoryName == null) return this
|
|
||||||
|
|
||||||
return directoryName.split(File.separatorChar).filter { it.isNotBlank() }.fold(this) { file: UniFile?, directory ->
|
|
||||||
file?.createDirectory(directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
// May give this error on scoped storage.
|
|
||||||
// W/DocumentsContract: Failed to create document
|
|
||||||
// java.lang.IllegalArgumentException: Parent document isn't a directory
|
|
||||||
|
|
||||||
// Not present in latest testing.
|
|
||||||
|
|
||||||
println("Going to dir $directoryName from ${this.uri} ---- ${this.filePath}")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Creates itself from parent if doesn't exist.
|
|
||||||
if (!this.exists() && createMissingDirectories && !this.name.isNullOrBlank()) {
|
|
||||||
if (this.parentFile != null) {
|
|
||||||
this.parentFile?.createDirectory(this.name)
|
|
||||||
} else if (this.filePath != null) {
|
|
||||||
UniFile.fromFile(File(this.filePath!!).parentFile)?.createDirectory(this.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val allDirectories = directoryName?.split("/")
|
|
||||||
return if (allDirectories?.size == 1 || allDirectories == null) {
|
|
||||||
val found = this.findFile(directoryName)
|
|
||||||
when {
|
|
||||||
directoryName.isNullOrBlank() -> this
|
|
||||||
found?.isDirectory == true -> found
|
|
||||||
|
|
||||||
!createMissingDirectories -> null
|
|
||||||
// Below creates directories
|
|
||||||
found?.isFile == true -> {
|
|
||||||
found.delete()
|
|
||||||
this.createDirectory(directoryName)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isDirectory -> this.createDirectory(directoryName)
|
|
||||||
else -> this.parentFile?.createDirectory(directoryName)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var currentDirectory = this
|
|
||||||
allDirectories.forEach {
|
|
||||||
// If the next directory is not found it returns the deepest directory possible.
|
|
||||||
val nextDir = currentDirectory.gotoDir(it, createMissingDirectories)
|
|
||||||
currentDirectory = nextDir ?: return null
|
|
||||||
}
|
|
||||||
currentDirectory
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logError(e)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getDisplayName(name: String, extension: String): String {
|
private fun getDisplayName(name: String, extension: String): String {
|
||||||
return "$name.$extension"
|
return "$name.$extension"
|
||||||
}
|
}
|
||||||
|
@ -1510,33 +1453,22 @@ object VideoDownloadManager {
|
||||||
* As of writing UniFile is used for everything but download directory on scoped storage.
|
* As of writing UniFile is used for everything but download directory on scoped storage.
|
||||||
* Special ContentResolver fuckery is needed for that as UniFile doesn't work.
|
* Special ContentResolver fuckery is needed for that as UniFile doesn't work.
|
||||||
* */
|
* */
|
||||||
fun getDownloadDir(): UniFile? {
|
fun getDefaultDir(context: Context): SafeFile? {
|
||||||
// See https://www.py4u.net/discuss/614761
|
// See https://www.py4u.net/discuss/614761
|
||||||
return UniFile.fromFile(
|
return SafeFile.fromMedia(
|
||||||
File(
|
context, MediaFileContentType.Downloads
|
||||||
Environment.getExternalStorageDirectory().absolutePath + File.separatorChar +
|
|
||||||
Environment.DIRECTORY_DOWNLOADS
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("TODO fix UniFile to work with download directory.")
|
|
||||||
private fun getRelativePath(folder: String?): String {
|
|
||||||
return (Environment.DIRECTORY_DOWNLOADS + '/' + folder + '/').replace(
|
|
||||||
'/',
|
|
||||||
File.separatorChar
|
|
||||||
).replace("${File.separatorChar}${File.separatorChar}", File.separatorChar.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a string to an UniFile. Used for stored string paths such as settings.
|
* Turns a string to an UniFile. Used for stored string paths such as settings.
|
||||||
* Should only be used to get a download path.
|
* Should only be used to get a download path.
|
||||||
* */
|
* */
|
||||||
private fun basePathToFile(context: Context, path: String?): UniFile? {
|
private fun basePathToFile(context: Context, path: String?): SafeFile? {
|
||||||
return when {
|
return when {
|
||||||
path.isNullOrBlank() -> getDownloadDir()
|
path.isNullOrBlank() -> getDefaultDir(context)
|
||||||
path.startsWith("content://") -> UniFile.fromUri(context, path.toUri())
|
path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri())
|
||||||
else -> UniFile.fromFile(File(path))
|
else -> SafeFile.fromFile(context, File(path))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1545,17 +1477,12 @@ object VideoDownloadManager {
|
||||||
* Returns the file and a string to be stored for future file retrieval.
|
* Returns the file and a string to be stored for future file retrieval.
|
||||||
* UniFile.filePath is not sufficient for storage.
|
* UniFile.filePath is not sufficient for storage.
|
||||||
* */
|
* */
|
||||||
fun Context.getBasePath(): Pair<UniFile?, String?> {
|
fun Context.getBasePath(): Pair<SafeFile?, String?> {
|
||||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||||
val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null)
|
val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null)
|
||||||
return basePathToFile(this, basePathSetting) to basePathSetting
|
return basePathToFile(this, basePathSetting) to basePathSetting
|
||||||
}
|
}
|
||||||
|
|
||||||
fun UniFile?.isDownloadDir(): Boolean {
|
|
||||||
return this != null && this.filePath == getDownloadDir()?.filePath
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String {
|
fun getFileName(context: Context, metadata: DownloadEpisodeMetadata): String {
|
||||||
return getFileName(context, metadata.name, metadata.episode, metadata.season)
|
return getFileName(context, metadata.name, metadata.episode, metadata.season)
|
||||||
}
|
}
|
||||||
|
@ -1596,7 +1523,7 @@ object VideoDownloadManager {
|
||||||
link: ExtractorLink,
|
link: ExtractorLink,
|
||||||
notificationCallback: (Int, Notification) -> Unit,
|
notificationCallback: (Int, Notification) -> Unit,
|
||||||
tryResume: Boolean = false,
|
tryResume: Boolean = false,
|
||||||
): Int {
|
): DownloadStatus {
|
||||||
val name = getFileName(context, ep)
|
val name = getFileName(context, ep)
|
||||||
|
|
||||||
// Make sure this is cancelled when download is done or cancelled.
|
// Make sure this is cancelled when download is done or cancelled.
|
||||||
|
@ -1638,7 +1565,7 @@ object VideoDownloadManager {
|
||||||
context,
|
context,
|
||||||
link,
|
link,
|
||||||
name,
|
name,
|
||||||
folder,
|
folder ?: "",
|
||||||
ep.id,
|
ep.id,
|
||||||
startIndex,
|
startIndex,
|
||||||
callback
|
callback
|
||||||
|
@ -1648,7 +1575,7 @@ object VideoDownloadManager {
|
||||||
context,
|
context,
|
||||||
link,
|
link,
|
||||||
name,
|
name,
|
||||||
folder,
|
folder ?: "",
|
||||||
"mp4",
|
"mp4",
|
||||||
tryResume,
|
tryResume,
|
||||||
ep.id,
|
ep.id,
|
||||||
|
@ -1656,7 +1583,7 @@ object VideoDownloadManager {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (t: Throwable) {
|
} catch (t: Throwable) {
|
||||||
return ERROR_UNKNOWN
|
return DOWNLOAD_FAILED
|
||||||
} finally {
|
} finally {
|
||||||
extractorJob.cancel()
|
extractorJob.cancel()
|
||||||
}
|
}
|
||||||
|
@ -1698,10 +1625,8 @@ object VideoDownloadManager {
|
||||||
notificationCallback,
|
notificationCallback,
|
||||||
resume
|
resume
|
||||||
)
|
)
|
||||||
//.also { println("Single episode finished with return code: $it") }
|
|
||||||
|
|
||||||
// retry every link at least once
|
if (connectionResult.retrySame) {
|
||||||
if (connectionResult <= 0) {
|
|
||||||
connectionResult = downloadSingleEpisode(
|
connectionResult = downloadSingleEpisode(
|
||||||
context,
|
context,
|
||||||
item.source,
|
item.source,
|
||||||
|
@ -1713,11 +1638,12 @@ object VideoDownloadManager {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionResult > 0) { // SUCCESS
|
if (connectionResult.success) { // SUCCESS
|
||||||
removeKey(KEY_RESUME_PACKAGES, id.toString())
|
removeKey(KEY_RESUME_PACKAGES, id.toString())
|
||||||
break
|
break
|
||||||
} else if (index == item.links.lastIndex) {
|
} else if (!connectionResult.tryNext || index >= item.links.lastIndex) {
|
||||||
downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed))
|
downloadStatusEvent.invoke(Pair(id, DownloadType.IsFailed))
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
@ -1731,62 +1657,69 @@ object VideoDownloadManager {
|
||||||
// return id
|
// return id
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
|
/* fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? {
|
||||||
val res = getDownloadFileInfo(context, id)
|
val res = getDownloadFileInfo(context, id)
|
||||||
if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
|
if (res == null) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
|
||||||
return res
|
return res
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
fun getDownloadFileInfoAndUpdateSettings(context: Context, id: Int): DownloadedFileInfoResult? =
|
||||||
|
getDownloadFileInfo(context, id, removeKeys = true)
|
||||||
|
|
||||||
|
private fun DownloadedFileInfo.toFile(context: Context): SafeFile? {
|
||||||
|
return basePathToFile(context, this.basePath)?.gotoDirectory(relativePath)
|
||||||
|
?.findFile(displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDownloadFileInfo(context: Context, id: Int): DownloadedFileInfoResult? {
|
private fun getDownloadFileInfo(
|
||||||
|
context: Context,
|
||||||
|
id: Int,
|
||||||
|
removeKeys: Boolean = false
|
||||||
|
): DownloadedFileInfoResult? {
|
||||||
try {
|
try {
|
||||||
val info =
|
val info =
|
||||||
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return null
|
context.getKey<DownloadedFileInfo>(KEY_DOWNLOAD_INFO, id.toString()) ?: return null
|
||||||
val base = basePathToFile(context, info.basePath)
|
val file = info.toFile(context)
|
||||||
val file = base?.gotoDir(info.relativePath, false)?.findFile(info.displayName)
|
|
||||||
if (file?.exists() != true) return null
|
|
||||||
|
|
||||||
return DownloadedFileInfoResult(file.size(), info.totalBytes, file.uri)
|
// only delete the key if the file is not found
|
||||||
|
if (file == null || !file.existsOrThrow()) {
|
||||||
|
if (removeKeys) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return DownloadedFileInfoResult(
|
||||||
|
file.lengthOrThrow(),
|
||||||
|
info.totalBytes,
|
||||||
|
file.uriOrThrow()
|
||||||
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the true download size as Scoped Storage sometimes wrongly returns 0.
|
|
||||||
* */
|
|
||||||
fun UniFile.size(): Long {
|
|
||||||
val len = length()
|
|
||||||
return if (len <= 1) {
|
|
||||||
println("LEN:::::::>>>>>>>>>>>>>>>>>>>>>>>$len")
|
|
||||||
val inputStream = this.openInputStream()
|
|
||||||
return inputStream.available().toLong().also { inputStream.closeQuietly() }
|
|
||||||
} else {
|
|
||||||
len
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
|
fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean {
|
||||||
val success = deleteFile(context, id)
|
val success = deleteFile(context, id)
|
||||||
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
|
if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString())
|
||||||
return success
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteFile(
|
/*private fun deleteFile(
|
||||||
context: Context,
|
context: Context,
|
||||||
folder: UniFile?,
|
folder: SafeFile?,
|
||||||
relativePath: String,
|
relativePath: String,
|
||||||
displayName: String
|
displayName: String
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val file = folder?.gotoDir(relativePath)?.findFile(displayName) ?: return false
|
val file = folder?.gotoDirectory(relativePath)?.findFile(displayName) ?: return false
|
||||||
if (!file.exists()) return true
|
if (file.exists() == false) return true
|
||||||
return try {
|
return try {
|
||||||
file.delete()
|
file.delete()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logError(e)
|
logError(e)
|
||||||
(context.contentResolver?.delete(file.uri, null, null) ?: return false) > 0
|
(context.contentResolver?.delete(file.uri() ?: return true, null, null)
|
||||||
|
?: return false) > 0
|
||||||
}
|
}
|
||||||
}
|
}*/
|
||||||
|
|
||||||
private fun deleteFile(context: Context, id: Int): Boolean {
|
private fun deleteFile(context: Context, id: Int): Boolean {
|
||||||
val info =
|
val info =
|
||||||
|
@ -1795,8 +1728,7 @@ object VideoDownloadManager {
|
||||||
downloadProgressEvent.invoke(Triple(id, 0, 0))
|
downloadProgressEvent.invoke(Triple(id, 0, 0))
|
||||||
downloadStatusEvent.invoke(id to DownloadType.IsStopped)
|
downloadStatusEvent.invoke(id to DownloadType.IsStopped)
|
||||||
downloadDeleteEvent.invoke(id)
|
downloadDeleteEvent.invoke(id)
|
||||||
val base = basePathToFile(context, info.basePath)
|
return info.toFile(context)?.delete() ?: false
|
||||||
return deleteFile(context, base, info.relativePath, info.displayName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {
|
fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? {
|
||||||
|
|
|
@ -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