Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

FileFlow scans your files periodically and organizes them according to your rules.

[<img src="https://github.com/user-attachments/assets/713d71c5-3dec-4ec4-a3f2-8d28d025a9c6" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" height="80" alt="Get it at IzzyOnDroid">](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [<img src="https://github.com/user-attachments/assets/713d71c5-3dec-4ec4-a3f2-8d28d025a9c6" alt="Get it on Obtainium" height="80">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)

## Screenshots

Expand Down
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission
android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="AllFilesAccessPolicy,ScopedStorage" />

<!-- region Remove unused permission(s) added by WorkManager -->
<uses-permission
Expand All @@ -15,6 +20,7 @@
android:allowBackup="${allowBackup}"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:requestLegacyExternalStorage="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/co/adityarajput/fileflow/Constants.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package co.adityarajput.fileflow

object Constants {
const val STATE = "state"
const val IS_FIRST_RUN = "is_first_run"

const val SETTINGS = "settings"
const val BRIGHTNESS = "brightness"

Expand Down
68 changes: 26 additions & 42 deletions app/src/main/java/co/adityarajput/fileflow/services/FlowExecutor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,29 @@ import co.adityarajput.fileflow.data.AppContainer
import co.adityarajput.fileflow.data.models.Action
import co.adityarajput.fileflow.data.models.Execution
import co.adityarajput.fileflow.data.models.Rule
import co.adityarajput.fileflow.utils.File
import co.adityarajput.fileflow.utils.Logger
import co.adityarajput.fileflow.utils.pathToFile
import co.adityarajput.fileflow.utils.copyFile
import kotlinx.coroutines.flow.first

class FlowExecutor(private val context: Context) {
private val repository by lazy { AppContainer(context).repository }

suspend fun run(rules: List<Rule>? = null) {
val resolver = context.contentResolver

for (rule in rules ?: repository.rules().first()) {
Logger.d("FlowExecutor", "Executing $rule")

if (!rule.enabled || rule.action !is Action.MOVE) continue

val regex = Regex(rule.action.srcFileNamePattern)
val destDir = context.pathToFile(rule.action.dest)
val destDir = File.fromPath(context, rule.action.dest)

if (destDir == null) {
Logger.e("FlowExecutor", "${rule.action.dest} is invalid")
continue
}

val srcFile = context.pathToFile(rule.action.src)?.listFiles()
val srcFile = File.fromPath(context, rule.action.src)?.listFiles()
?.filter { it.isFile && it.name != null && regex.matches(it.name!!) }
?.maxByOrNull(rule.action.superlative.selector)
?: continue
Expand All @@ -42,56 +41,41 @@ class FlowExecutor(private val context: Context) {
continue
}

resolver.openInputStream(srcFile.uri).use { src ->
resolver.openInputStream(destFile.uri).use { dest ->
if (src == null || dest == null) {
Logger.e("FlowExecutor", "Failed to open file(s)")
continue
}

if (src.readBytes().contentEquals(dest.readBytes())) {
Logger.i(
"FlowExecutor",
"Source and destination files are identical",
)
continue
}
}
if (srcFile.isIdenticalTo(destFile, context)) {
Logger.i(
"FlowExecutor",
"Source and destination files are identical",
)
continue
}


Logger.i("FlowExecutor", "Deleting existing ${destFile.name}")
destFile.delete()
}

destFile = destDir.createFile(
srcFile.type ?: "application/octet-stream",
destFileName,
)
destFile = destDir.createFile(srcFile.type, destFileName)

if (destFile == null) {
Logger.e("FlowExecutor", "Failed to create $destFileName")
continue
}

resolver.openInputStream(srcFile.uri).use { src ->
resolver.openOutputStream(destFile.uri).use { dest ->
if (src == null || dest == null) {
Logger.e("FlowExecutor", "Failed to open file(s)")
continue
}

Logger.i("FlowExecutor", "Copying ${srcFile.name} to ${destFile.name}")
src.copyTo(dest)
repository.registerExecution(
rule,
Execution(srcFile.name!!, rule.action.verb),
)
val result = context.copyFile(srcFile, destFile)
if (!result) {
Logger.e("FlowExecutor", "Failed to copy ${srcFile.name} to ${destFile.name}")
destFile.delete()
continue
}

if (!rule.action.keepOriginal) {
Logger.i("FlowExecutor", "Deleting original ${srcFile.name}")
srcFile.delete()
}
}
repository.registerExecution(
rule,
Execution(srcFile.name!!, rule.action.verb),
)

if (!rule.action.keepOriginal) {
Logger.i("FlowExecutor", "Deleting original ${srcFile.name}")
srcFile.delete()
}
}
}
Expand Down
141 changes: 134 additions & 7 deletions app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,140 @@ package co.adityarajput.fileflow.utils
import android.content.Context
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import co.adityarajput.fileflow.R
import java.net.URLDecoder
import java.io.File as IOFile

fun Context.pathToFile(path: String) = DocumentFile.fromTreeUri(this, path.toUri())
sealed class File {
companion object {
fun fromPath(context: Context, path: String): File? {
try {
if (context.isGranted(Permission.MANAGE_EXTERNAL_STORAGE)) {
val ioFile = IOFile(path)
if (ioFile.exists())
return FSFile(ioFile)
} else {
val docFile = DocumentFile.fromTreeUri(context, path.toUri())
if (docFile != null && docFile.exists())
return SAFFile(docFile)
}
} catch (e: Exception) {
Logger.e("Files", "Error while creating file from path: $path", e)
}

enum class FileSuperlative(val displayName: Int, val selector: (DocumentFile) -> Long) {
EARLIEST(R.string.earliest, { -it.lastModified() }),
LATEST(R.string.latest, { it.lastModified() }),
SMALLEST(R.string.smallest, { -it.length() }),
LARGEST(R.string.largest, { it.length() }),
return null
}
}

class SAFFile(val documentFile: DocumentFile) : File()

class FSFile(val ioFile: IOFile) : File()

val name
get() = when (this) {
is SAFFile -> documentFile.name
is FSFile -> ioFile.name
}

val isFile
get() = when (this) {
is SAFFile -> documentFile.isFile
is FSFile -> ioFile.isFile
}

val type
get() = when (this) {
is SAFFile -> documentFile.type
is FSFile -> null
}

fun lastModified() = when (this) {
is SAFFile -> documentFile.lastModified()
is FSFile -> ioFile.lastModified()
}

fun length() = when (this) {
is SAFFile -> documentFile.length()
is FSFile -> ioFile.length()
}

fun listFiles() = when (this) {
is SAFFile -> documentFile.listFiles().map { SAFFile(it) }
is FSFile -> ioFile.listFiles()?.map { FSFile(it) } ?: emptyList()
}

fun isIdenticalTo(other: File, context: Context): Boolean {
val resolver = context.contentResolver

if (this is FSFile && other is FSFile) {
return ioFile.readBytes().contentEquals(other.ioFile.readBytes())
} else if (this is SAFFile && other is SAFFile) {
resolver.openInputStream(documentFile.uri).use { src ->
resolver.openInputStream(other.documentFile.uri).use { dest ->
if (src == null || dest == null) {
Logger.e("Files", "Failed to open file(s)")
return false
}
return src.readBytes().contentEquals(dest.readBytes())
}
}
}

return false
}

fun createFile(type: String?, name: String): File? {
return when (this) {
is SAFFile -> documentFile
.createFile(type ?: "application/octet-stream", name)
?.let { SAFFile(it) }

is FSFile -> IOFile(ioFile, name)
.let { if (it.createNewFile()) FSFile(it) else null }
}
}

fun delete() = when (this) {
is SAFFile -> documentFile.delete()
is FSFile -> ioFile.delete()
}
}

fun Context.copyFile(src: File, dest: File): Boolean {
val resolver = contentResolver

if (src is File.SAFFile && dest is File.SAFFile) {
resolver.openInputStream(src.documentFile.uri).use { srcStream ->
resolver.openOutputStream(dest.documentFile.uri).use { destStream ->
if (srcStream == null || destStream == null) {
Logger.e("Files", "Failed to open file(s)")
return false
}
Logger.i("Files", "Copying ${src.name} to ${dest.name}")
srcStream.copyTo(destStream)
return true
}
}
} else if (src is File.FSFile && dest is File.FSFile) {
src.ioFile.inputStream().use { srcStream ->
dest.ioFile.outputStream().use { destStream ->
Logger.i("Files", "Copying ${src.name} to ${dest.name}")
srcStream.copyTo(destStream)
return true
}
}
}

return false
}

fun String.getGetDirectoryFromUri() =
if (isBlank()) {
this
} else if (this.contains(':')) {
"/" + URLDecoder.decode(this, "UTF-8").split(":").last()
} else {
var file = IOFile(this)
while (file.parentFile?.canRead() ?: false) file = file.parentFile!!

substringAfter(file.name ?: "").ifBlank { "/" }
}
67 changes: 61 additions & 6 deletions app/src/main/java/co/adityarajput/fileflow/utils/Permissions.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,73 @@
package co.adityarajput.fileflow.utils

import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
import android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
import android.content.pm.PackageManager.PERMISSION_GRANTED
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.PowerManager
import android.provider.Settings
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri

fun Context.takePersistablePermission(uri: Uri) =
enum class Permission {
UNRESTRICTED_BACKGROUND_USAGE,
MANAGE_EXTERNAL_STORAGE,
}

fun Context.isGranted(permission: Permission) = when (permission) {
Permission.UNRESTRICTED_BACKGROUND_USAGE ->
(getSystemService(POWER_SERVICE) as PowerManager)
.isIgnoringBatteryOptimizations(packageName)

Permission.MANAGE_EXTERNAL_STORAGE ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) Environment.isExternalStorageManager()
else ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE,
) == PERMISSION_GRANTED
}

fun Context.isGranted(permissions: Iterable<Permission>) =
permissions.associateWith(::isGranted).withDefault { false }

@SuppressLint("BatteryLife")
fun Context.request(permission: Permission, remove: Boolean = false) = try {
val uri = "package:$packageName".toUri()

when (permission) {
Permission.UNRESTRICTED_BACKGROUND_USAGE ->
startActivity(
if (remove)
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
else
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, uri),
)

Permission.MANAGE_EXTERNAL_STORAGE ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
startActivity(Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, uri))
else
ActivityCompat.requestPermissions(
this as Activity,
arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE),
0,
)
}
} catch (e: Exception) {
Logger.e("Permissions", "Error while requesting $permission", e)
}

fun Context.requestPersistableFolderPermission(uri: Uri) =
contentResolver.takePersistableUriPermission(
uri,
FLAG_GRANT_READ_URI_PERMISSION or FLAG_GRANT_WRITE_URI_PERMISSION,
)

fun Context.hasUnrestrictedBackgroundUsagePermission(): Boolean {
return (getSystemService(Context.POWER_SERVICE) as PowerManager)
.isIgnoringBatteryOptimizations(this.packageName)
}
3 changes: 0 additions & 3 deletions app/src/main/java/co/adityarajput/fileflow/utils/String.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import co.adityarajput.fileflow.R
import java.net.URLDecoder

@Composable
fun Long.toShortHumanReadableTime(): String {
Expand All @@ -29,5 +28,3 @@ fun Long.toShortHumanReadableTime(): String {
@Composable
fun Boolean.getToggleString(): String =
stringResource(if (this) R.string.disable else R.string.enable)

fun String.getGetDirectoryFromUri() = URLDecoder.decode(this, "UTF-8").split(":").last()
Loading