You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ytdl-android/app/src/main/java/com/deniscerri/ytdl/util/FileUtil.kt

467 lines
20 KiB
Kotlin

package com.deniscerri.ytdl.util
import android.R.attr.mimeType
import android.app.Activity
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.util.Log
import android.view.ViewGroup
import android.view.Window
import android.webkit.MimeTypeMap
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.core.view.isVisible
import androidx.documentfile.provider.DocumentFile
import androidx.preference.Preference
import androidx.preference.PreferenceManager
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.callback.FolderCallback
import com.anggrayudi.storage.file.copyFolderTo
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.moveFileTo
import com.deniscerri.ytdl.App
import com.deniscerri.ytdl.BuildConfig
import com.deniscerri.ytdl.MainActivity
import com.deniscerri.ytdl.R
import com.deniscerri.ytdl.util.Extensions.getMediaDuration
import com.deniscerri.ytdl.util.Extensions.toStringDuration
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.yausername.youtubedl_android.YoutubeDLRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.internal.closeQuietly
import okhttp3.internal.format
import okhttp3.internal.lowercase
import java.io.File
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardCopyOption
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
import kotlin.io.path.absolutePathString
import kotlin.math.log10
import kotlin.math.pow
object FileUtil {
fun deleteFile(path: String){
runCatching {
if (!File(path).delete()){
DocumentFile.fromSingleUri(App.instance, Uri.parse(path))?.delete()
}
deleteFileFromMediaStore(path)
}
}
private fun deleteFileFromMediaStore(path: String) {
val contentResolver = App.instance.contentResolver
val file = File(path)
val uri = MediaStore.Files.getContentUri("external")
val selection: String
val selectionArgs: Array<String>
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val parentPath = file.parentFile?.absolutePath.orEmpty()
val primaryRoot = Environment.getExternalStorageDirectory().absolutePath
if (parentPath.startsWith(primaryRoot)) {
val trimmed = parentPath
.removePrefix(primaryRoot)
.removePrefix(File.separator)
val relativePath = if (trimmed.isEmpty()) "" else "$trimmed${File.separator}"
selection = MediaStore.MediaColumns.RELATIVE_PATH + " =? AND " +
MediaStore.MediaColumns.DISPLAY_NAME + " =?"
selectionArgs = arrayOf(relativePath, file.name)
} else {
// Non-primary storage: fall back to DATA query
selection = MediaStore.MediaColumns.DATA + " =?"
selectionArgs = arrayOf(file.absolutePath)
}
} else {
selection = MediaStore.MediaColumns.DATA + " =?"
selectionArgs = arrayOf(file.absolutePath)
}
contentResolver.delete(uri, selection, selectionArgs)
}
fun exists(path: String) : Boolean {
val file = File(path)
if (path.isEmpty()) return false
return file.exists()
}
fun formatPath(path: String) : String {
var dataValue = path
if (dataValue.startsWith("/storage/")) return dataValue
dataValue = dataValue.replace("content://com.android.externalstorage.documents/tree/", "")
dataValue = dataValue.replace("raw:/storage/", "")
dataValue = dataValue.replace("^/document/".toRegex(), "")
dataValue = dataValue.replace("^primary:".toRegex(), "primary/")
dataValue = dataValue.replace("%3A".toRegex(), "/")
try {
dataValue = URLDecoder.decode(dataValue, StandardCharsets.UTF_8.name())
} catch (ignored: Exception) {
}
val pieces = dataValue.split("/").toTypedArray()
val formattedPath = StringBuilder("/storage/")
if (pieces[0] == "primary"){
formattedPath.append("emulated/0/")
}else{
formattedPath.append(pieces[0]).append("/")
}
pieces.forEachIndexed { i, it ->
if (i > 0 && it.isNotEmpty()){
formattedPath.append(it).append("/")
}
}
return formattedPath.toString()
}
@Throws(Exception::class)
suspend fun moveFile(originDir: File, context: Context, destDir: String, keepCache: Boolean, progress: (p: Int) -> Unit) : List<String> {
return withContext(Dispatchers.Main){
val fileList = mutableListOf<String>()
val dir = File(formatPath(destDir))
if (!dir.exists()) dir.mkdirs()
originDir.walk().forEach {
if (it.isDirectory && it.absolutePath == originDir.absolutePath) return@forEach
var destFile: DocumentFile
try {
if (
it.name.matches("(^config.*.\\.txt\$)|(rList)|(.*.part-Frag.*)|(.*.live_chat)|(.*.ytdl)".toRegex())
|| it.length() == 0L
){
return@forEach
}
runCatching {
if (File(formatPath(destDir)).canWrite()){
val files = it.listFiles()?.filter { fil -> !fil.isDirectory }?.toTypedArray() ?: arrayOf(it)
for (ff in files){
val newFile = File(dir.absolutePath + "/${ff.absolutePath.removePrefix(originDir.absolutePath)}")
runCatching {
newFile.parentFile?.mkdirs()
}
if (Build.VERSION.SDK_INT >= 26 ) {
var newFileName = newFile.absolutePath
var counter = 1
while (Files.exists(File(newFileName).toPath())) {
// If the file already exists in the destination directory, add a number to differentiate it
newFileName = newFile.absolutePath.replace(newFile.nameWithoutExtension, newFile.nameWithoutExtension+" ($counter)")
counter++
}
fileList.add(Files.move(
ff.toPath(),
File(newFileName).toPath(),
StandardCopyOption.REPLACE_EXISTING
).absolutePathString())
ff.delete()
fileList.add(newFileName)
}else{
var newFileName = newFile.absolutePath
var counter = 1
while (File(newFileName).exists()) {
// If the file already exists in the destination directory, add a number to differentiate it
newFileName = newFile.absolutePath.replace(newFile.nameWithoutExtension, newFile.nameWithoutExtension+" ($counter)")
counter++
}
ff.copyTo(File(newFileName),false)
ff.delete()
fileList.add(newFileName)
}
}
return@forEach
}
}
val curr = DocumentFile.fromFile(it)
val dst = DocumentFile.fromTreeUri(context, destDir.toUri())
if (it.isDirectory){
withContext(Dispatchers.IO){
curr.copyFolderTo(context, dst!!, skipEmptyFiles = false, callback = object : FolderCallback() {
override fun onStart(folder: DocumentFile, totalFilesToCopy: Int, workerThread: Thread): Long {
return 500 // update progress every half second
}
override fun onParentConflict(destinationFolder: DocumentFile, action: ParentFolderConflictAction, canMerge: Boolean) {
if (canMerge){
action.confirmResolution(ConflictResolution.MERGE)
}else{
action.confirmResolution(ConflictResolution.CREATE_NEW)
}
}
override fun onReport(report: Report) {
progress(report.progress.toInt())
}
override fun onCompleted(result: Result) {
fileList.addAll(result.folder.listFiles().map { f -> f.getAbsolutePath(context) })
it.deleteRecursively()
}
override fun onFailed(errorCode: ErrorCode) {
//if its usb?
runCatching {
it.walkTopDown().forEach { f ->
if (f.isDirectory) return@forEach
val destUri = moveFileInputStream(it, context, dst) ?: return@forEach
fileList.add(DocumentFile.fromSingleUri(context, destUri)!!.getAbsolutePath(context))
}
it.deleteRecursively()
}
super.onFailed(errorCode)
}
})
}
}else{
withContext(Dispatchers.IO){
curr.moveFileTo(context, dst!!, callback = object : FileCallback() {
override fun onFailed(errorCode: ErrorCode) {
//if its usb?
runCatching {
val destUri = moveFileInputStream(it, context, dst) ?: return
fileList.add(DocumentFile.fromSingleUri(context, destUri)!!.getAbsolutePath(context))
it.delete()
}
super.onFailed(errorCode)
}
override fun onConflict(
destinationFile: DocumentFile,
action: FileConflictAction
) {
action.confirmResolution(ConflictResolution.CREATE_NEW)
}
override fun onStart(file: Any, workerThread: Thread): Long {
return 500 // update progress every 1 second
}
override fun onReport(report: Report) {
progress(report.progress.toInt())
}
override fun onCompleted(result: Any) {
destFile = (result as DocumentFile)
fileList.add(destFile.getAbsolutePath(context))
it.delete()
super.onCompleted(result)
}
})
}
}
}catch (e: Exception) {
Log.e("error", e.message.toString())
}
}
if (!keepCache){
originDir.deleteRecursively()
}
return@withContext scanMedia(fileList, context)
}
}
private fun moveFileInputStream(it: File, context: Context, dst: DocumentFile) : Uri? {
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(it.extension) ?: "*/*"
val destUri = DocumentsContract.createDocument(
context.contentResolver,
dst.uri,
mimeType,
it.name
) ?: return null
val inputStream = it.inputStream()
val outputStream =
context.contentResolver.openOutputStream(destUri) ?: return null
inputStream.copyTo(outputStream)
inputStream.closeQuietly()
outputStream.closeQuietly()
return destUri
}
fun scanMedia(files: List<String>, context: Context) : List<String> {
try {
val paths = files.distinct().sortedByDescending { File(it).length() }
runCatching {
paths.forEach {
MediaScannerConnection.scanFile(context, arrayOf(it), null, null)
}
}
return paths.sortedBy { File(it).lastModified() }
}catch (e: Exception){
e.printStackTrace()
}
return listOf()
}
fun getBackupPath(context: Context) : String {
val preference = PreferenceManager.getDefaultSharedPreferences(context).getString("backup_path", "")
return if (preference.isNullOrBlank() || !File(formatPath(preference)).canWrite()) {
getDefaultApplicationPath() + "/Backups"
}else {
formatPath(preference)
}
}
fun getCachePath(context: Context) : String {
val preference = PreferenceManager.getDefaultSharedPreferences(context).getString("cache_path", "")
if (preference.isNullOrBlank() || !File(formatPath(preference)).canWrite()) {
val externalPath = context.getExternalFilesDir(null)
return if (externalPath == null){
context.cacheDir.absolutePath + "/downloads/"
}else{
externalPath.absolutePath + "/downloads/"
}
}else {
return formatPath(preference)
}
}
fun deleteConfigFiles(request: YoutubeDLRequest) {
runCatching {
request.getArguments("--config")?.forEach {
if (it != null) File(it).delete()
}
request.getArguments("--config-locations")?.forEach {
if (it != null) File(it).delete()
}
request.getArguments("-o")?.firstOrNull { it?.startsWith("infojson:") == true }?.apply {
File(this.removePrefix("infojson:")).delete()
}
}
}
fun getDefaultAudioPath() : String{
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + File.separator + "YTDLnis/Audio"
}
fun getDefaultVideoPath() : String{
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + File.separator + "YTDLnis/Video"
}
fun getDefaultCommandPath() : String {
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + File.separator + "YTDLnis/Command"
}
fun getDefaultApplicationPath() : String {
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + File.separator + "YTDLnis"
}
fun getDownloadArchivePath(context: Context) : String {
var folder = PreferenceManager.getDefaultSharedPreferences(context).getString("download_archive_path", "")!!
if (folder == "") {
val externalPath = context.getExternalFilesDir(null)
folder = if (externalPath == null){
context.cacheDir.absolutePath + File.separator
}else{
externalPath.absolutePath + File.separator
}
}
return "${formatPath(folder)}download_archive.txt"
}
fun getDefaultTerminalPath() : String {
return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath + File.separator + "YTDLnis/TERMINAL_CACHE"
}
fun getCookieFile(context : Context, ignoreIfExists: Boolean = false, path: (path: String) -> Unit){
val cookiesFile = File(context.cacheDir, "cookies.txt")
if (ignoreIfExists || cookiesFile.exists()){
path(cookiesFile.absolutePath)
}
}
fun convertFileSize(s: Long): String{
if (s <= 1) return "?"
val units = arrayOf("B", "kB", "MB", "GB", "TB")
val digitGroups = (log10(s.toDouble()) / log10(1024.0)).toInt()
val symbols = DecimalFormatSymbols(Locale.US)
return "${DecimalFormat("#,##0.#", symbols).format(s / 1024.0.pow(digitGroups.toDouble()))} ${units[digitGroups]}"
}
fun openFileIntent(context: Context, downloadPath: String) {
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", File(downloadPath))
println(uri)
if (uri == null){
Toast.makeText(context, "Error opening file!", Toast.LENGTH_SHORT).show()
}else{
val ext = downloadPath.substring(downloadPath.lastIndexOf(".") + 1).lowercase()
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
val intent = Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, mime)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
context.startActivity(intent)
}
}
fun shareFileIntent(context: Context, paths: List<String>){
val uris : ArrayList<Uri> = arrayListOf()
paths.runCatching {
this.forEach {path ->
val uri = DocumentFile.fromSingleUri(context, Uri.parse(path)).run{
if (this?.exists() == true){
this.uri
}else if (File(path).exists()){
FileProvider.getUriForFile(context, context.packageName + ".fileprovider",
File(path))
}else null
}
if (uri != null) uris.add(uri)
}
}
if (uris.isEmpty()){
Toast.makeText(context, "Error sharing files!", Toast.LENGTH_SHORT).show()
}else{
Intent().apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
action = Intent.ACTION_SEND_MULTIPLE
putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris)
type = if (uris.size == 1) uris[0].let { context.contentResolver.getType(it) } ?: "media/*" else "*/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}.run{
context.startActivity(Intent.createChooser(this, context.getString(R.string.share)))
}
}
}
fun hasAllFilesAccess() : Boolean {
if (Build.VERSION.SDK_INT < 30) return true
return Environment.isExternalStorageManager()
}
}