package com.vgmlr.varnish
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.graphics.Rect
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.provider.OpenableColumns
import android.provider.Settings
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import java.io.File
import java.util.Locale
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
data class MediaFile(
val path: String,
val name: String,
val folder: String,
val size: Long,
val modified: Long,
val video: Boolean
)
data class MediaFolder(
val path: String,
val name: String,
val count: Int,
val covers: List<MediaFile>,
val locked: Boolean
) {
val cover: MediaFile? get() = covers.firstOrNull()
}
data class ScanResult(
val folders: List<MediaFolder> = emptyList(),
val files: Map<String, List<MediaFile>> = emptyMap()
)
data class MediaInfo(
val name: String,
val path: String,
val size: String,
val resolution: String,
val modified: String
)
data class AppChoice(val label: String, val pkg: String)
class VarnishRepo(context: Context) {
private val app = context.applicationContext
private val dao = VarnishDb.get(app).marks()
@Volatile
private var rootPaths: List<String> = emptyList()
// access
fun hasAccess(): Boolean = Environment.isExternalStorageManager()
fun accessIntent(): Intent = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
"package:${app.packageName}".toUri()
)
// scan
@Suppress("DEPRECATION")
fun roots(): List<String> {
val primary = Environment.getExternalStorageDirectory().absolutePath
val extra = app.getExternalFilesDirs(null).drop(1).mapNotNull {
it?.absolutePath?.substringBefore("/Android/", "")?.takeIf(String::isNotEmpty)
}
return (listOf(primary) + extra).distinct()
.filter { File(it).canRead() }
.also { rootPaths = it }
}
suspend fun scan(s: VarnishSettings): ScanResult = withContext(Dispatchers.IO) {
val found = HashMap<String, List<MediaFile>>()
for (root in roots().map(::File)) {
if (s.scan == ScanMode.FOLDERS) {
root.listFiles()?.forEach { d ->
ensureActive()
if (d.isDirectory && enterable(d, s)) {
mediaIn(d, s)?.takeIf { it.isNotEmpty() }?.let { found[d.absolutePath] = it }
}
}
} else {
val stack = ArrayDeque<File>().apply { add(root) }
while (stack.isNotEmpty()) {
ensureActive()
val dir = stack.removeLast()
val kids = dir.listFiles() ?: continue
if (!s.showHidden && kids.any { it.name == NOMEDIA }) continue
val media = ArrayList<MediaFile>()
for (k in kids) {
if (k.isDirectory) {
if (enterable(k, s)) stack.add(k)
} else {
toMedia(k, s)?.let(media::add)
}
}
if (media.isNotEmpty()) found[dir.absolutePath] = media
}
}
}
dao.pathsNow(MarkKind.PINNED).forEach { p ->
val d = File(p)
if (p !in found && d.isDirectory && enterable(d, s)) {
found[p] = mediaIn(d, s) ?: emptyList()
}
}
val folders = found.map { (p, list) -> folderOf(File(p), list) }
.sortedWith(compareBy(NaturalOrder) { f: MediaFolder -> f.name }.thenBy { it.path })
ScanResult(folders, found)
}
suspend fun folderFiles(path: String, s: VarnishSettings): List<MediaFile> =
withContext(Dispatchers.IO) {
val list = File(path).listFiles()
?.mapNotNull { if (it.isFile) toMedia(it, s) else null }
.orEmpty()
sortFiles(list, s.sort)
}
suspend fun mediaAt(paths: Collection<String>, s: VarnishSettings): List<MediaFile> =
withContext(Dispatchers.IO) {
val list = paths.mapNotNull { p -> File(p).takeIf { it.isFile }?.let { toMedia(it, s) } }
sortFiles(list, s.sort)
}
fun search(result: ScanResult, q: String, sort: SortMode): List<MediaFile> {
val t = q.trim()
if (t.isEmpty()) return emptyList()
val date = VarnishClock.matcher(t)
val hits = result.files.values.flatten().filter {
it.name.contains(t, true) ||
File(it.folder).name.contains(t, true) ||
date?.invoke(it.modified) == true
}
return sortFiles(hits, sort)
}
fun sortFiles(list: List<MediaFile>, sort: SortMode): List<MediaFile> {
val name = compareBy(NaturalOrder) { f: MediaFile -> f.name }
val by = when (sort) {
SortMode.AZ -> name
SortMode.ZA -> name.reversed()
SortMode.NEW -> compareByDescending<MediaFile> { it.modified }.then(name)
SortMode.OLD -> compareBy<MediaFile> { it.modified }.then(name)
}
return list.sortedWith(by)
}
fun isLocked(dir: File): Boolean {
val roots = rootPaths.ifEmpty { roots() }
val p = dir.absolutePath
if (p in roots) return true
val parent = dir.parent
if (parent != null && parent in roots &&
VarnishConfig.SYSTEM_DIRS.any { it.equals(dir.name, true) }
) {
return true
}
if (roots.any { p.startsWith("$it/Android/") }) return true
return !dir.canWrite()
}
fun virtualFolder(path: String, name: String, list: List<MediaFile>): MediaFolder =
MediaFolder(path, name, list.size, coversOf(list), locked = true)
private fun folderOf(dir: File, list: List<MediaFile>): MediaFolder =
MediaFolder(dir.absolutePath, dir.name, list.size, coversOf(list), isLocked(dir))
private fun coversOf(list: List<MediaFile>): List<MediaFile> {
val (vids, imgs) = list.partition { it.video }
return (
imgs.sortedByDescending { it.modified } + vids.sortedByDescending { it.modified }
).take(COVERS)
}
private fun mediaIn(dir: File, s: VarnishSettings): List<MediaFile>? {
val kids = dir.listFiles() ?: return null
if (!s.showHidden && kids.any { it.name == NOMEDIA }) return null
return kids.mapNotNull { if (it.isFile) toMedia(it, s) else null }
}
private fun enterable(dir: File, s: VarnishSettings): Boolean {
if (dir.parentFile?.name == "Android" && dir.name in SEALED) return false
return s.showHidden || !dir.name.startsWith(".")
}
private fun toMedia(f: File, s: VarnishSettings): MediaFile? {
if (!s.showHidden && f.name.startsWith(".")) return null
val video = videoFlag(f.name, s.types) ?: return null
return MediaFile(f.absolutePath, f.name, f.parent.orEmpty(), f.length(), f.lastModified(), video)
}
private fun videoFlag(name: String, types: FileTypes): Boolean? {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return when {
ext in VarnishConfig.IMAGE_EXT && types != FileTypes.VIDEOS -> false
ext in VarnishConfig.VIDEO_EXT && types != FileTypes.IMAGES -> true
else -> null
}
}
// marks
fun marks(kind: Int): Flow<Set<String>> = dao.paths(kind).map { it.toHashSet() }
suspend fun setMarks(kind: Int, paths: Collection<String>, on: Boolean) =
withContext(Dispatchers.IO) {
paths.chunked(500).forEach { c ->
if (on) dao.add(c.map { Mark(it, kind) }) else dao.remove(kind, c)
}
}
// file ops
suspend fun deleteFiles(paths: List<String>): List<String> =
withContext(Dispatchers.IO) { deleteNow(paths) }
suspend fun deleteFolder(path: String): Boolean =
withContext(Dispatchers.IO) {
val dir = File(path)
if (isLocked(dir)) return@withContext false
val inner = dir.walkBottomUp().filter { it.isFile }.map { it.absolutePath }.toList()
val ok = dir.deleteRecursively()
dao.purge(path, treeLike(path))
inner.forEach { VarnishThumbs.drop(app, it) }
rescan(inner)
ok
}
suspend fun renameFile(path: String, base: String): String? = withContext(Dispatchers.IO) {
val src = File(path)
val name = base.trim()
if (!validName(name)) return@withContext null
val dst = File(src.parentFile, if (src.extension.isEmpty()) name else "$name.${src.extension}")
if (dst.absolutePath == path) return@withContext path
if (dst.exists() || !src.renameTo(dst)) return@withContext null
dao.move(path, dst.absolutePath)
VarnishThumbs.move(app, path, dst.absolutePath)
rescan(listOf(path, dst.absolutePath))
dst.absolutePath
}
suspend fun renameFolder(path: String, newName: String): String? = withContext(Dispatchers.IO) {
val src = File(path)
val name = newName.trim()
if (isLocked(src) || !validName(name)) return@withContext null
val dst = File(src.parentFile, name)
if (dst.absolutePath == path) return@withContext path
if (dst.exists()) return@withContext null
val old = src.walk().filter { it.isFile }.map { it.absolutePath }.toList()
if (!src.renameTo(dst)) return@withContext null
val to = dst.absolutePath
dao.move(path, to)
dao.moveTree(to, path.length + 1, treeLike(path))
old.forEach { VarnishThumbs.move(app, it, to + it.substring(path.length)) }
rescan(old + old.map { to + it.substring(path.length) })
to
}
suspend fun createFolder(parent: String, name: String): String? = withContext(Dispatchers.IO) {
val n = name.trim()
if (!validName(n)) return@withContext null
val dir = freeFile(File(parent), n, "")
if (!dir.mkdirs()) return@withContext null
dao.add(listOf(Mark(dir.absolutePath, MarkKind.PINNED)))
dir.absolutePath
}
suspend fun moveFile(path: String, destDir: String): String? = withContext(Dispatchers.IO) {
val src = File(path)
val dir = File(destDir)
if (!dir.isDirectory || dir.absolutePath == src.parent) return@withContext null
val dst = freeFile(dir, src.nameWithoutExtension, src.extension)
val moved = src.renameTo(dst) || runCatching {
src.copyTo(dst)
dst.setLastModified(src.lastModified())
src.delete()
true
}.getOrDefault(false)
if (!moved) return@withContext null
dao.move(path, dst.absolutePath)
VarnishThumbs.move(app, path, dst.absolutePath)
rescan(listOf(path, dst.absolutePath))
dst.absolutePath
}
suspend fun cropSave(path: String, rect: Rect, s: VarnishSettings): String? =
withContext(Dispatchers.IO) {
VarnishCodec.crop(File(path), rect, s.outFormat, s.quality, s.keepExif)
?.absolutePath
?.also { rescan(listOf(it)) }
}
suspend fun info(path: String): MediaInfo = withContext(Dispatchers.IO) {
val f = File(path)
val dim = VarnishCodec.bounds(f)
MediaInfo(
name = f.name,
path = f.parent.orEmpty(),
size = sizeLabel(f.length()),
resolution = dim?.let { "${it.first} × ${it.second}" } ?: "-",
modified = VarnishClock.stamp(f.lastModified())
)
}
fun treePath(uri: Uri): String? {
val id = runCatching { DocumentsContract.getTreeDocumentId(uri) }.getOrNull() ?: return null
val dir = idFile(id)
return dir.absolutePath.takeIf { dir.isDirectory }
}
suspend fun resolve(uri: Uri): String? = withContext(Dispatchers.IO) {
val p = when (uri.scheme) {
"file" -> uri.path?.takeIf { it.startsWith("/") }
"content" -> docPath(uri) ?: dataPath(uri)
else -> null
}
p?.takeIf { !hasAccess() || File(it).canRead() } ?: copyIn(uri)
}
private fun idFile(id: String): File {
val vol = id.substringBefore(':')
val rel = id.substringAfter(':', "")
@Suppress("DEPRECATION")
val base = if (vol.equals("primary", true)) {
Environment.getExternalStorageDirectory().absolutePath
} else "/storage/$vol"
return if (rel.isEmpty()) File(base) else File(base, rel)
}
private fun docPath(uri: Uri): String? {
if (uri.authority != EXT_DOCS || !DocumentsContract.isDocumentUri(app, uri)) return null
val id = runCatching { DocumentsContract.getDocumentId(uri) }.getOrNull() ?: return null
return idFile(id).absolutePath
}
@Suppress("DEPRECATION")
private fun dataPath(uri: Uri): String? {
val src = if (DocumentsContract.isDocumentUri(app, uri)) {
runCatching { MediaStore.getMediaUri(app, uri) }.getOrNull() ?: uri
} else uri
return runCatching {
app.contentResolver.query(src, arrayOf(MediaStore.MediaColumns.DATA), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
}.getOrNull()?.takeIf { it.startsWith("/") }
}
private fun copyIn(uri: Uri): String? = runCatching {
val cr = app.contentResolver
val ext = MimeTypeMap.getSingleton().getExtensionFromMimeType(cr.getType(uri)) ?: "jpg"
val raw = cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { c -> if (c.moveToFirst()) c.getString(0) else null }
val name = raw?.substringAfterLast('/')?.takeIf(::validName) ?: "image"
val known = name.substringAfterLast('.', "").lowercase(Locale.ROOT) in VarnishConfig.IMAGE_EXT
val dir = File(app.cacheDir, INBOX).apply { deleteRecursively(); mkdirs() }
val dst = File(dir, if (known) name else "$name.$ext")
val input = cr.openInputStream(uri) ?: return null
input.use { i -> dst.outputStream().use { i.copyTo(it) } }
dst.absolutePath
}.getOrNull()
private suspend fun deleteNow(paths: List<String>): List<String> {
val gone = paths.filter { File(it).let { f -> !f.exists() || f.delete() } }
gone.forEach {
dao.purge(it, treeLike(it))
VarnishThumbs.drop(app, it)
}
rescan(gone)
return gone
}
private fun validName(n: String) = n.isNotEmpty() && '/' !in n && n != "." && n != ".."
private fun rescan(paths: List<String>) {
if (paths.isNotEmpty()) {
MediaScannerConnection.scanFile(app, paths.toTypedArray(), null, null)
}
}
// intents
fun shareIntent(paths: List<String>): Intent? {
val uris = paths.mapNotNull(::uriOf)
if (uris.isEmpty()) return null
val mimes = paths.map(::mimeOf).distinct()
val mime = when {
mimes.size == 1 -> mimes[0]
mimes.all { it.startsWith("image/") } -> "image/*"
mimes.all { it.startsWith("video/") } -> "video/*"
else -> "*/*"
}
val send = if (uris.size == 1) {
Intent(Intent.ACTION_SEND).putExtra(Intent.EXTRA_STREAM, uris[0])
} else {
Intent(Intent.ACTION_SEND_MULTIPLE)
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris))
}
send.type = mime
send.clipData = ClipData.newRawUri(null, uris[0]).apply {
uris.drop(1).forEach { addItem(ClipData.Item(it)) }
}
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
return Intent.createChooser(send, null)
}
fun editIntent(path: String, pkg: String): Intent? =
handoff(Intent.ACTION_EDIT, path, pkg, write = true)
fun playIntent(path: String, pkg: String): Intent? =
handoff(Intent.ACTION_VIEW, path, pkg, write = false)
fun apps(action: String, mime: String): List<AppChoice> {
val probe = Intent(action).setDataAndType("content://$PROBE/x".toUri(), mime)
return resolve(probe).mapNotNull { ri ->
val pkg = ri.activityInfo?.packageName ?: return@mapNotNull null
if (pkg == app.packageName) null
else AppChoice(ri.loadLabel(app.packageManager).toString(), pkg)
}.distinctBy { it.pkg }.sortedWith(compareBy(NaturalOrder) { it.label })
}
private fun handoff(action: String, path: String, pkg: String, write: Boolean): Intent? {
val uri = uriOf(path) ?: return null
var flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
if (write) flags = flags or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
val i = Intent(action).setDataAndType(uri, mimeOf(path)).addFlags(flags)
if (pkg.isNotEmpty()) i.setPackage(pkg)
if (resolve(i).isEmpty()) return null
return if (pkg.isEmpty()) Intent.createChooser(i, null) else i
}
@Suppress("DEPRECATION")
private fun resolve(i: Intent): List<ResolveInfo> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
app.packageManager.queryIntentActivities(i, PackageManager.ResolveInfoFlags.of(0))
} else {
app.packageManager.queryIntentActivities(i, 0)
}
private fun uriOf(path: String): Uri? = runCatching {
FileProvider.getUriForFile(app, "${app.packageName}.files", File(path))
}.getOrNull()
fun mimeOf(path: String): String {
val ext = path.substringAfterLast('.', "").lowercase(Locale.ROOT)
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)
?: if (ext in VarnishConfig.VIDEO_EXT) "video/*" else "image/*"
}
private companion object {
const val NOMEDIA = ".nomedia"
const val PROBE = "com.vgmlr.varnish.probe"
const val EXT_DOCS = "com.android.externalstorage.documents"
const val INBOX = "open"
const val COVERS = 3
val SEALED = setOf("data", "obb")
}
}
fun sizeLabel(bytes: Long): String {
if (bytes < 1024) return "$bytes B"
val units = arrayOf("KB", "MB", "GB", "TB")
var v = bytes / 1024.0
var i = 0
while (v >= 1024 && i < units.lastIndex) {
v /= 1024
i++
}
return String.format(Locale.US, "%.1f %s", v, units[i])
}