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. -[Get it on Obtainium](https://apps.obtainium.imranr.dev/redirect?r=obtainium://add/https://github.com/BURG3R5/FileFlow) +[Get it at IzzyOnDroid](https://apt.izzysoft.de/packages/co.adityarajput.fileflow) [Get it on Obtainium](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