diff --git a/README.md b/README.md
index 5372d12..559904a 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
FileFlow scans your files periodically and organizes them according to your rules.
-[
](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)
+[
](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [
](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow)
## Screenshots
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 411cf24..da928e5 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
? = 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
@@ -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()
}
}
}
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
index 20d72fd..dfd33a5 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Files.kt
@@ -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 { "/" }
+ }
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Permissions.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Permissions.kt
index c33e293..826e86e 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/Permissions.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Permissions.kt
@@ -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) =
+ 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)
-}
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
index c3abf3b..a7baa65 100644
--- a/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/String.kt
@@ -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 {
@@ -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()
diff --git a/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt b/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt
new file mode 100644
index 0000000..28c4233
--- /dev/null
+++ b/app/src/main/java/co/adityarajput/fileflow/utils/Superlatives.kt
@@ -0,0 +1,10 @@
+package co.adityarajput.fileflow.utils
+
+import co.adityarajput.fileflow.R
+
+enum class FileSuperlative(val displayName: Int, val selector: (File) -> 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() }),
+}
diff --git a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
index 72b7f6d..a9eab40 100644
--- a/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/viewmodels/UpsertRuleViewModel.kt
@@ -8,9 +8,10 @@ import androidx.lifecycle.ViewModel
import co.adityarajput.fileflow.data.Repository
import co.adityarajput.fileflow.data.models.Action
import co.adityarajput.fileflow.data.models.Rule
+import co.adityarajput.fileflow.utils.File
import co.adityarajput.fileflow.utils.FileSuperlative
import co.adityarajput.fileflow.utils.Logger
-import co.adityarajput.fileflow.utils.pathToFile
+import co.adityarajput.fileflow.views.components.FolderPickerState
class UpsertRuleViewModel(
rule: Rule?,
@@ -54,11 +55,13 @@ class UpsertRuleViewModel(
else State(Values(rule), null),
)
+ var folderPickerState by mutableStateOf(null)
+
fun updateForm(context: Context, values: Values) {
var currentSrcFileNames: List? = null
try {
if (values.src.isNotBlank())
- currentSrcFileNames = context.pathToFile(values.src)!!.listFiles()
+ currentSrcFileNames = File.fromPath(context, values.src)!!.listFiles()
.filter { it.isFile && it.name != null }.map { it.name!! }
} catch (e: Exception) {
Logger.e("UpsertRuleViewModel", "Couldn't fetch files in ${values.src}", e)
@@ -94,7 +97,8 @@ class UpsertRuleViewModel(
values.destFileNameTemplate.isBlank()
) return FormError.BLANK_FIELDS
- Regex(values.srcFileNamePattern).pattern == values.srcFileNamePattern
+ if (Regex(values.srcFileNamePattern).pattern != values.srcFileNamePattern)
+ return FormError.INVALID_REGEX
if (values.predictedDestFileNames == null) return FormError.INVALID_TEMPLATE
} catch (_: Exception) {
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/Navigator.kt b/app/src/main/java/co/adityarajput/fileflow/views/Navigator.kt
index 9cdab73..7c06e41 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/Navigator.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/Navigator.kt
@@ -1,20 +1,40 @@
package co.adityarajput.fileflow.views
+import android.content.Context.MODE_PRIVATE
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.core.content.edit
import androidx.navigation.NavHostController
+import androidx.navigation.NavOptions
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
+import co.adityarajput.fileflow.Constants.IS_FIRST_RUN
+import co.adityarajput.fileflow.Constants.STATE
import co.adityarajput.fileflow.viewmodels.AppearanceViewModel
import co.adityarajput.fileflow.views.screens.*
import kotlinx.serialization.Serializable
@Composable
fun Navigator(controller: NavHostController, appearanceViewModel: AppearanceViewModel) {
+ val isFirstRun = remember {
+ controller.context.getSharedPreferences(STATE, MODE_PRIVATE).getBoolean(IS_FIRST_RUN, true)
+ }
+
NavHost(
controller,
- Routes.RULES.name,
+ if (isFirstRun) Routes.ONBOARDING.name else Routes.RULES.name,
) {
+ composable(Routes.ONBOARDING.name) {
+ OnboardingScreen {
+ controller.context.getSharedPreferences(STATE, MODE_PRIVATE)
+ .edit { putBoolean(IS_FIRST_RUN, false) }
+ controller.navigate(
+ Routes.RULES.name,
+ NavOptions.Builder().setPopUpTo(Routes.ONBOARDING.name, true).build(),
+ )
+ }
+ }
composable(Routes.RULES.name) {
RulesScreen(
{ controller.navigate(UpsertRuleRoute(it)) },
@@ -43,6 +63,7 @@ fun Navigator(controller: NavHostController, appearanceViewModel: AppearanceView
}
enum class Routes {
+ ONBOARDING,
RULES,
EXECUTIONS,
SETTINGS,
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/components/FolderPickerBottomSheet.kt b/app/src/main/java/co/adityarajput/fileflow/views/components/FolderPickerBottomSheet.kt
new file mode 100644
index 0000000..7c4857a
--- /dev/null
+++ b/app/src/main/java/co/adityarajput/fileflow/views/components/FolderPickerBottomSheet.kt
@@ -0,0 +1,123 @@
+package co.adityarajput.fileflow.views.components
+
+import android.os.Environment
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import co.adityarajput.fileflow.R
+import co.adityarajput.fileflow.viewmodels.UpsertRuleViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun FolderPickerBottomSheet(viewModel: UpsertRuleViewModel) {
+ val context = LocalContext.current
+ val hideSheet = { viewModel.folderPickerState = null }
+ var currentDir by remember { mutableStateOf(Environment.getExternalStorageDirectory()) }
+ val items = remember(currentDir) {
+ currentDir.listFiles()?.sortedBy { it.name.lowercase() }?.sortedBy { it.isFile }
+ ?: emptyList()
+ }
+
+ ModalBottomSheet(
+ hideSheet,
+ sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
+ ) {
+ Text(
+ currentDir.path,
+ Modifier.padding(
+ dimensionResource(R.dimen.padding_medium),
+ dimensionResource(R.dimen.padding_small),
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ overflow = TextOverflow.StartEllipsis,
+ maxLines = 1,
+ )
+ HorizontalDivider()
+ LazyColumn(
+ Modifier.padding(dimensionResource(R.dimen.padding_medium)),
+ verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
+ ) {
+ if (currentDir.parentFile?.canRead() ?: false) {
+ item {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable { currentDir = currentDir.parentFile },
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ ) {
+ Icon(
+ painterResource(R.drawable.arrow_back),
+ stringResource(R.string.parent_directory),
+ )
+ Text(stringResource(R.string.parent_directory))
+ }
+ }
+ }
+ if (items.isEmpty()) {
+ item {
+ Text(
+ stringResource(R.string.empty_folder),
+ Modifier.fillMaxWidth(),
+ Color.Gray,
+ textAlign = TextAlign.Center,
+ )
+ }
+ } else {
+ items(items, { it.path }) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable(it.isDirectory) { currentDir = it },
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ ) {
+ Icon(
+ painterResource(if (it.isDirectory) R.drawable.folder else R.drawable.file),
+ stringResource(if (it.isDirectory) R.string.folder else R.string.file),
+ )
+ Text(it.name)
+ }
+ }
+ }
+ }
+ Button(
+ {
+ viewModel.updateForm(
+ context,
+ viewModel.state.values.run {
+ if (viewModel.folderPickerState == FolderPickerState.SRC) copy(src = currentDir.path)
+ else copy(dest = currentDir.path)
+ },
+ )
+ hideSheet()
+ },
+ Modifier
+ .align(Alignment.CenterHorizontally)
+ .padding(vertical = dimensionResource(R.dimen.padding_small)),
+ colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer),
+ ) {
+ Text(
+ stringResource(R.string.use_this_folder),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Normal,
+ )
+ }
+ }
+}
+
+enum class FolderPickerState { SRC, DEST }
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/OnboardingScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/OnboardingScreen.kt
new file mode 100644
index 0000000..5c2da60
--- /dev/null
+++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/OnboardingScreen.kt
@@ -0,0 +1,88 @@
+package co.adityarajput.fileflow.views.screens
+
+import android.annotation.SuppressLint
+import android.os.Handler
+import android.os.Looper
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import co.adityarajput.fileflow.R
+import co.adityarajput.fileflow.utils.Permission
+import co.adityarajput.fileflow.utils.isGranted
+import co.adityarajput.fileflow.utils.request
+import co.adityarajput.fileflow.views.components.AppBar
+
+private val permissions = listOf(
+ Permission.UNRESTRICTED_BACKGROUND_USAGE,
+ Permission.MANAGE_EXTERNAL_STORAGE,
+)
+
+@SuppressLint("BatteryLife")
+@Composable
+fun OnboardingScreen(goToRulesScreen: () -> Unit = {}) {
+ val context = LocalContext.current
+ val handler = remember { Handler(Looper.getMainLooper()) }
+
+ var hasPermissions by remember { mutableStateOf(context.isGranted(permissions)) }
+ var hasSkipped by remember { mutableStateOf(false) }
+
+ val watcher = object : Runnable {
+ override fun run() {
+ hasPermissions = context.isGranted(permissions)
+ if (
+ (hasPermissions.getValue(Permission.UNRESTRICTED_BACKGROUND_USAGE) || hasSkipped) &&
+ hasPermissions.getValue(Permission.MANAGE_EXTERNAL_STORAGE)
+ ) goToRulesScreen()
+ else handler.postDelayed(this, 500)
+ }
+ }
+ DisposableEffect(Unit) {
+ handler.post(watcher)
+ onDispose { handler.removeCallbacksAndMessages(null) }
+ }
+
+ Scaffold(topBar = { AppBar(stringResource(R.string.app_name), false) }) { paddingValues ->
+ Box(
+ Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ ) {
+ Column(
+ Modifier
+ .fillMaxSize()
+ .padding(dimensionResource(R.dimen.padding_extra_large)),
+ Arrangement.Center,
+ Alignment.CenterHorizontally,
+ ) {
+ if (!hasPermissions.getValue(Permission.UNRESTRICTED_BACKGROUND_USAGE) && !hasSkipped) {
+ Text(stringResource(R.string.onboarding_info_1))
+ Button(
+ { context.request(Permission.UNRESTRICTED_BACKGROUND_USAGE) },
+ Modifier.padding(top = dimensionResource(R.dimen.padding_large)),
+ colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer),
+ ) { Text(stringResource(R.string.disable_optimization)) }
+ TextButton({ hasSkipped = true }) {
+ Text(stringResource(R.string.skip))
+ }
+ } else if (!hasPermissions.getValue(Permission.MANAGE_EXTERNAL_STORAGE)) {
+ Text(stringResource(R.string.onboarding_info_2))
+ Button(
+ { context.request(Permission.MANAGE_EXTERNAL_STORAGE) },
+ Modifier.padding(top = dimensionResource(R.dimen.padding_large)),
+ colors = ButtonDefaults.buttonColors(contentColor = MaterialTheme.colorScheme.onPrimaryContainer),
+ ) { Text(stringResource(R.string.grant_permission)) }
+ TextButton(goToRulesScreen) {
+ Text(stringResource(R.string.skip))
+ }
+ } else {
+ CircularProgressIndicator()
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt
index 8cb63bc..b5a086d 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/SettingsScreen.kt
@@ -1,13 +1,10 @@
package co.adityarajput.fileflow.views.screens
-import android.annotation.SuppressLint
import android.content.ClipData
import android.content.Context.MODE_PRIVATE
-import android.content.Intent
import android.os.Build
import android.os.Handler
import android.os.Looper
-import android.provider.Settings
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -25,18 +22,23 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.core.content.edit
-import androidx.core.net.toUri
import co.adityarajput.fileflow.Constants.BRIGHTNESS
import co.adityarajput.fileflow.Constants.SETTINGS
import co.adityarajput.fileflow.R
import co.adityarajput.fileflow.utils.Logger
-import co.adityarajput.fileflow.utils.hasUnrestrictedBackgroundUsagePermission
+import co.adityarajput.fileflow.utils.Permission
+import co.adityarajput.fileflow.utils.isGranted
+import co.adityarajput.fileflow.utils.request
import co.adityarajput.fileflow.viewmodels.AppearanceViewModel
import co.adityarajput.fileflow.views.Brightness
import co.adityarajput.fileflow.views.components.AppBar
import kotlinx.coroutines.launch
-@SuppressLint("BatteryLife")
+private val permissions = listOf(
+ Permission.UNRESTRICTED_BACKGROUND_USAGE,
+ Permission.MANAGE_EXTERNAL_STORAGE,
+)
+
@Composable
fun SettingsScreen(
goToLicensesScreen: () -> Unit = {},
@@ -51,13 +53,11 @@ fun SettingsScreen(
val sharedPreferences =
remember { context.getSharedPreferences(SETTINGS, MODE_PRIVATE) }
- var isInvincible by remember {
- mutableStateOf(context.hasUnrestrictedBackgroundUsagePermission())
- }
+ var hasPermissions by remember { mutableStateOf(context.isGranted(permissions)) }
val watcher = object : Runnable {
override fun run() {
- isInvincible = context.hasUnrestrictedBackgroundUsagePermission()
+ hasPermissions = context.isGranted(permissions)
handler.postDelayed(this, 1000)
}
}
@@ -86,51 +86,57 @@ fun SettingsScreen(
.fillMaxWidth()
.padding(dimensionResource(R.dimen.padding_small)),
) {
- Text(
- stringResource(R.string.settings_section_1),
+ Column(
Modifier.padding(
dimensionResource(R.dimen.padding_large),
dimensionResource(R.dimen.padding_medium),
),
- fontWeight = FontWeight.Medium,
- )
- Row(
- Modifier
- .fillMaxWidth()
- .padding(
- start = dimensionResource(R.dimen.padding_large),
- end = dimensionResource(R.dimen.padding_large),
- bottom = dimensionResource(R.dimen.padding_medium),
- ),
- Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
- Alignment.CenterVertically,
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)),
) {
- Column(Modifier.weight(1f)) {
- Text(
- stringResource(R.string.disable_battery_optimization),
- style = MaterialTheme.typography.titleSmall,
+ Text(
+ stringResource(R.string.settings_section_1),
+ fontWeight = FontWeight.Medium,
+ )
+ Row(
+ Modifier.fillMaxWidth(),
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.disable_battery_optimization),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Text(
+ stringResource(R.string.explain_disabling_battery_optimization),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Switch(
+ hasPermissions.getValue(Permission.UNRESTRICTED_BACKGROUND_USAGE),
+ { context.request(Permission.UNRESTRICTED_BACKGROUND_USAGE) },
)
- Text(
- stringResource(R.string.explain_disabling_battery_optimization),
- style = MaterialTheme.typography.bodySmall,
+ }
+ Row(
+ Modifier.fillMaxWidth(),
+ Arrangement.spacedBy(dimensionResource(R.dimen.padding_small)),
+ Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f)) {
+ Text(
+ stringResource(R.string.all_files_access),
+ style = MaterialTheme.typography.titleSmall,
+ )
+ Text(
+ stringResource(R.string.explain_all_files_access),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Switch(
+ hasPermissions.getValue(Permission.MANAGE_EXTERNAL_STORAGE),
+ { context.request(Permission.MANAGE_EXTERNAL_STORAGE) },
)
}
- Switch(
- isInvincible,
- {
- if (it) {
- val intent = Intent(
- Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
- "package:${context.packageName}".toUri(),
- )
- context.startActivity(intent)
- } else {
- val intent =
- Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
- context.startActivity(intent)
- }
- },
- )
}
}
Card(
diff --git a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
index a45d1ad..dcea548 100644
--- a/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
+++ b/app/src/main/java/co/adityarajput/fileflow/views/screens/UpsertRuleScreen.kt
@@ -20,16 +20,12 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.lifecycle.viewmodel.compose.viewModel
import co.adityarajput.fileflow.R
-import co.adityarajput.fileflow.utils.FileSuperlative
-import co.adityarajput.fileflow.utils.getGetDirectoryFromUri
-import co.adityarajput.fileflow.utils.takePersistablePermission
+import co.adityarajput.fileflow.utils.*
import co.adityarajput.fileflow.viewmodels.FormError
import co.adityarajput.fileflow.viewmodels.FormWarning
import co.adityarajput.fileflow.viewmodels.Provider
import co.adityarajput.fileflow.viewmodels.UpsertRuleViewModel
-import co.adityarajput.fileflow.views.components.AppBar
-import co.adityarajput.fileflow.views.components.ErrorText
-import co.adityarajput.fileflow.views.components.WarningText
+import co.adityarajput.fileflow.views.components.*
import kotlinx.coroutines.launch
@Composable
@@ -111,12 +107,14 @@ fun UpsertRuleScreen(
}
}
}
+ if (viewModel.folderPickerState != null) FolderPickerBottomSheet(viewModel)
}
}
@Composable
private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
val context = LocalContext.current
+ val shouldUseCustomPicker = remember { context.isGranted(Permission.MANAGE_EXTERNAL_STORAGE) }
var superlativeDropdownExpanded by remember { mutableStateOf(false) }
@@ -125,7 +123,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
ActivityResultContracts.OpenDocumentTree(),
) { uri ->
uri ?: return@rememberLauncherForActivityResult
- context.takePersistablePermission(uri)
+ context.requestPersistableFolderPermission(uri)
viewModel.updateForm(context, viewModel.state.values.copy(src = uri.toString()))
}
val destPicker =
@@ -133,7 +131,7 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
ActivityResultContracts.OpenDocumentTree(),
) { uri ->
uri ?: return@rememberLauncherForActivityResult
- context.takePersistablePermission(uri)
+ context.requestPersistableFolderPermission(uri)
viewModel.updateForm(context, viewModel.state.values.copy(dest = uri.toString()))
}
@@ -149,7 +147,10 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
},
Modifier
.fillMaxWidth()
- .clickable { srcPicker.launch(null) },
+ .clickable {
+ if (shouldUseCustomPicker) viewModel.folderPickerState = FolderPickerState.SRC
+ else srcPicker.launch(null)
+ },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
)
@@ -237,7 +238,10 @@ private fun ColumnScope.ActionPage(viewModel: UpsertRuleViewModel) {
},
Modifier
.fillMaxWidth()
- .clickable { destPicker.launch(null) },
+ .clickable {
+ if (shouldUseCustomPicker) viewModel.folderPickerState = FolderPickerState.DEST
+ else destPicker.launch(null)
+ },
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Normal,
)
diff --git a/app/src/main/res/drawable/file.xml b/app/src/main/res/drawable/file.xml
new file mode 100644
index 0000000..5460450
--- /dev/null
+++ b/app/src/main/res/drawable/file.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/folder.xml b/app/src/main/res/drawable/folder.xml
new file mode 100644
index 0000000..0e2a5ea
--- /dev/null
+++ b/app/src/main/res/drawable/folder.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 82b9591..54cbfe6 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -8,7 +8,7 @@
App logo
" scans your files periodically and organizes them according to your rules."
GPLv3
Source code available on GitHub
Visit the project wiki for more]]>
- - REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: to request exemption from background usage restrictions
Other permissions may be used by libraries to manage tasks, such as executing flows periodically.]]>
+ - REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: to request exemption from background usage restrictions
- WRITE/MANAGE_EXTERNAL_STORAGE: for targeting subfolders or protected locations
Other permissions may be used by libraries to manage tasks, such as executing flows periodically.]]>
BURG3R5]]>
Back button
@@ -28,6 +28,14 @@
Licenses
+
+ "FileFlow scans your files periodically and organizes them according to your rules.\n\nOn some devices, the 'battery optimization' feature leads to delayed execution."
+ Disable optimization
+ Skip
+ "To use advanced features such as targeting subfolders or protected locations, FileFlow will need all files access.\n\nNo data is sent off-device; FileFlow doesn't have access to the internet."
+ Grant permission
+
+
Add rule
No rules added.\nTap + to get started.
@@ -57,6 +65,8 @@
Optional Enhancements
Disable optimization
Exempts the app from being restricted by the OS
+ All files access
+ Lets flows target subfolders or protected locations
App Appearance
App Theme
Light
@@ -93,6 +103,13 @@
Regex pattern contains errors
Regex template contains errors
Regex pattern doesn\'t match any file in the source folder
+
+ Back
+ (Empty)
+ Folder
+ File
+ Use this folder
+
earliest
latest