diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7acb022b..4af08a0d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -44,50 +44,59 @@ android {
}
}
-composeCompiler {
- enableStrongSkippingMode = true
-}
-
dependencies {
- implementation(libs.androidx.profileinstaller)
"baselineProfile"(project(":baselineprofile"))
+ implementation(libs.androidx.profileinstaller)
coreLibraryDesugaring(libs.desugar.jdk.libs)
- implementation(libs.androidx.core.ktx)
- implementation(libs.androidx.lifecycle.runtime.ktx)
+ // Local/File-based dependencies
+ implementation(files("libs/APKEditor.jar"))
+
+ // AndroidX - Core & Lifecycle
+ implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.compose.android)
- implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.material)
+ // Jetpack Compose
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.foundation)
- implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.icons.extended)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.ui.tooling.preview.android)
+ // Other Jetpack & Android Libraries
+ implementation(libs.androidx.datastore)
+ implementation(libs.androidx.media3.exoplayer)
+ implementation(libs.androidx.media3.ui)
+ implementation(libs.androidx.palette.ktx)
+
+ // Sora Code Editor
implementation(libs.sora.editor)
implementation(libs.sora.editor.language.java)
implementation(libs.sora.editor.language.textmate)
+ // Image Loading - Coil
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.svg)
implementation(libs.coil.video)
- implementation(libs.gson)
- implementation(libs.androidx.datastore)
-
+ // Third-Party UI/Compose Utilities
+ implementation(libs.accompanist.systemuicontroller)
implementation(libs.cascade.compose)
implementation(libs.compose.swipebox)
- implementation(libs.reorderable)
- implementation(libs.storage)
implementation(libs.grid)
implementation(libs.lazycolumnscrollbar)
+ implementation(libs.reorderable)
implementation(libs.zoomable)
- implementation(libs.androidx.media3.exoplayer)
- implementation(libs.androidx.media3.ui)
- // APKEditor
- implementation(files("libs/APKEditor.jar"))
+ // Third-Party General Utilities
implementation(libs.apksig)
+ implementation(libs.commons.net)
+ implementation(libs.gson)
+ implementation(libs.storage)
+ implementation(libs.zip4j)
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cf4c2ec7..270e91f6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,12 +3,17 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+
+
+
@@ -58,6 +64,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
(Ljava/lang/Object;Landro
HSPLandroidx/compose/animation/core/Animatable;->(Ljava/lang/Object;Landroidx/compose/animation/core/TwoWayConverter;Ljava/lang/Object;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
HSPLandroidx/compose/animation/core/Animatable;->access$clampToBounds(Landroidx/compose/animation/core/Animatable;Ljava/lang/Object;)Ljava/lang/Object;
HSPLandroidx/compose/animation/core/Animatable;->access$endAnimation(Landroidx/compose/animation/core/Animatable;)V
-HSPLandroidx/compose/animation/core/Animatable;->access$setRunning(Landroidx/compose/animation/core/Animatable;Z)V
+HSPLandroidx/compose/animation/core/Animatable;->access$setShowDialog(Landroidx/compose/animation/core/Animatable;Z)V
HSPLandroidx/compose/animation/core/Animatable;->access$setTargetValue(Landroidx/compose/animation/core/Animatable;Ljava/lang/Object;)V
HSPLandroidx/compose/animation/core/Animatable;->asState()Landroidx/compose/runtime/State;
HSPLandroidx/compose/animation/core/Animatable;->clampToBounds(Ljava/lang/Object;)Ljava/lang/Object;
@@ -1172,7 +1172,7 @@ HSPLandroidx/compose/animation/core/Animatable;->getTypeConverter()Landroidx/com
HSPLandroidx/compose/animation/core/Animatable;->getValue()Ljava/lang/Object;
HSPLandroidx/compose/animation/core/Animatable;->getVelocityVector()Landroidx/compose/animation/core/AnimationVector;
HSPLandroidx/compose/animation/core/Animatable;->runAnimation(Landroidx/compose/animation/core/Animation;Ljava/lang/Object;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
-HSPLandroidx/compose/animation/core/Animatable;->setRunning(Z)V
+HSPLandroidx/compose/animation/core/Animatable;->setShowDialog(Z)V
HSPLandroidx/compose/animation/core/Animatable;->setTargetValue(Ljava/lang/Object;)V
Landroidx/compose/animation/core/Animatable$runAnimation$2;
HSPLandroidx/compose/animation/core/Animatable$runAnimation$2;->(Landroidx/compose/animation/core/Animatable;Ljava/lang/Object;Landroidx/compose/animation/core/Animation;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)V
@@ -1227,10 +1227,10 @@ HSPLandroidx/compose/animation/core/AnimationScope;->getLastFrameTimeNanos()J
HSPLandroidx/compose/animation/core/AnimationScope;->getStartTimeNanos()J
HSPLandroidx/compose/animation/core/AnimationScope;->getValue()Ljava/lang/Object;
HSPLandroidx/compose/animation/core/AnimationScope;->getVelocityVector()Landroidx/compose/animation/core/AnimationVector;
-HSPLandroidx/compose/animation/core/AnimationScope;->isRunning()Z
+HSPLandroidx/compose/animation/core/AnimationScope;->getShowDialog()Z
HSPLandroidx/compose/animation/core/AnimationScope;->setFinishedTimeNanos$animation_core_release(J)V
HSPLandroidx/compose/animation/core/AnimationScope;->setLastFrameTimeNanos$animation_core_release(J)V
-HSPLandroidx/compose/animation/core/AnimationScope;->setRunning$animation_core_release(Z)V
+HSPLandroidx/compose/animation/core/AnimationScope;->setShowDialog$animation_core_release(Z)V
HSPLandroidx/compose/animation/core/AnimationScope;->setValue$animation_core_release(Ljava/lang/Object;)V
HSPLandroidx/compose/animation/core/AnimationScope;->setVelocityVector$animation_core_release(Landroidx/compose/animation/core/AnimationVector;)V
Landroidx/compose/animation/core/AnimationSpec;
@@ -1257,10 +1257,10 @@ HSPLandroidx/compose/animation/core/AnimationState;->getLastFrameTimeNanos()J
HSPLandroidx/compose/animation/core/AnimationState;->getTypeConverter()Landroidx/compose/animation/core/TwoWayConverter;
HSPLandroidx/compose/animation/core/AnimationState;->getValue()Ljava/lang/Object;
HSPLandroidx/compose/animation/core/AnimationState;->getVelocityVector()Landroidx/compose/animation/core/AnimationVector;
-HSPLandroidx/compose/animation/core/AnimationState;->isRunning()Z
+HSPLandroidx/compose/animation/core/AnimationState;->getShowDialog()Z
HSPLandroidx/compose/animation/core/AnimationState;->setFinishedTimeNanos$animation_core_release(J)V
HSPLandroidx/compose/animation/core/AnimationState;->setLastFrameTimeNanos$animation_core_release(J)V
-HSPLandroidx/compose/animation/core/AnimationState;->setRunning$animation_core_release(Z)V
+HSPLandroidx/compose/animation/core/AnimationState;->setShowDialog$animation_core_release(Z)V
HSPLandroidx/compose/animation/core/AnimationState;->setValue$animation_core_release(Ljava/lang/Object;)V
HSPLandroidx/compose/animation/core/AnimationState;->setVelocityVector$animation_core_release(Landroidx/compose/animation/core/AnimationVector;)V
Landroidx/compose/animation/core/AnimationStateKt;
@@ -1411,8 +1411,8 @@ HSPLandroidx/compose/animation/core/InfiniteTransition;->access$setStartTimeNano
PLandroidx/compose/animation/core/InfiniteTransition;->access$setStartTimeNanos$p(Landroidx/compose/animation/core/InfiniteTransition;J)V
HSPLandroidx/compose/animation/core/InfiniteTransition;->addAnimation$animation_core_release(Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;)V
PLandroidx/compose/animation/core/InfiniteTransition;->addAnimation$animation_core_release(Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;)V
-HSPLandroidx/compose/animation/core/InfiniteTransition;->isRunning()Z
-PLandroidx/compose/animation/core/InfiniteTransition;->isRunning()Z
+HSPLandroidx/compose/animation/core/InfiniteTransition;->getShowDialog()Z
+PLandroidx/compose/animation/core/InfiniteTransition;->getShowDialog()Z
HSPLandroidx/compose/animation/core/InfiniteTransition;->onFrame(J)V
PLandroidx/compose/animation/core/InfiniteTransition;->onFrame(J)V
PLandroidx/compose/animation/core/InfiniteTransition;->removeAnimation$animation_core_release(Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;)V
@@ -1420,8 +1420,8 @@ HSPLandroidx/compose/animation/core/InfiniteTransition;->run$animation_core_rele
PLandroidx/compose/animation/core/InfiniteTransition;->run$animation_core_release(Landroidx/compose/runtime/Composer;I)V
HSPLandroidx/compose/animation/core/InfiniteTransition;->setRefreshChildNeeded(Z)V
PLandroidx/compose/animation/core/InfiniteTransition;->setRefreshChildNeeded(Z)V
-HSPLandroidx/compose/animation/core/InfiniteTransition;->setRunning(Z)V
-PLandroidx/compose/animation/core/InfiniteTransition;->setRunning(Z)V
+HSPLandroidx/compose/animation/core/InfiniteTransition;->setShowDialog(Z)V
+PLandroidx/compose/animation/core/InfiniteTransition;->setShowDialog(Z)V
Landroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;
HSPLandroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;->(Landroidx/compose/animation/core/InfiniteTransition;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/animation/core/TwoWayConverter;Landroidx/compose/animation/core/AnimationSpec;Ljava/lang/String;)V
PLandroidx/compose/animation/core/InfiniteTransition$TransitionAnimationState;->(Landroidx/compose/animation/core/InfiniteTransition;Ljava/lang/Object;Ljava/lang/Object;Landroidx/compose/animation/core/TwoWayConverter;Landroidx/compose/animation/core/AnimationSpec;Ljava/lang/String;)V
@@ -1691,7 +1691,7 @@ HSPLandroidx/compose/animation/core/Transition;->getStartTimeNanos$animation_cor
HSPLandroidx/compose/animation/core/Transition;->getTargetState()Ljava/lang/Object;
HSPLandroidx/compose/animation/core/Transition;->getUpdateChildrenNeeded()Z
HSPLandroidx/compose/animation/core/Transition;->get_playTimeNanos()J
-HSPLandroidx/compose/animation/core/Transition;->isRunning()Z
+HSPLandroidx/compose/animation/core/Transition;->getShowDialog()Z
HSPLandroidx/compose/animation/core/Transition;->isSeeking()Z
HSPLandroidx/compose/animation/core/Transition;->onChildAnimationUpdated()V
PLandroidx/compose/animation/core/Transition;->onDisposed$animation_core_release()V
@@ -1812,8 +1812,8 @@ Landroidx/compose/animation/core/TransitionState;
HSPLandroidx/compose/animation/core/TransitionState;->()V
HSPLandroidx/compose/animation/core/TransitionState;->()V
HSPLandroidx/compose/animation/core/TransitionState;->(Lkotlin/jvm/internal/DefaultConstructorMarker;)V
-HSPLandroidx/compose/animation/core/TransitionState;->isRunning$animation_core_release()Z
-HSPLandroidx/compose/animation/core/TransitionState;->setRunning$animation_core_release(Z)V
+HSPLandroidx/compose/animation/core/TransitionState;->getShowDialog$animation_core_release()Z
+HSPLandroidx/compose/animation/core/TransitionState;->setShowDialog$animation_core_release(Z)V
Landroidx/compose/animation/core/TweenSpec;
HSPLandroidx/compose/animation/core/TweenSpec;->()V
HSPLandroidx/compose/animation/core/TweenSpec;->(IILandroidx/compose/animation/core/Easing;)V
diff --git a/app/src/main/java/com/raival/compose/file/explorer/App.kt b/app/src/main/java/com/raival/compose/file/explorer/App.kt
index 40155001..13184b10 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/App.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/App.kt
@@ -3,7 +3,6 @@ package com.raival.compose.file.explorer
import android.app.Application
import android.content.Context
import android.os.Process
-import android.util.Log
import android.widget.Toast
import androidx.annotation.StringRes
import coil3.ImageLoader
@@ -19,13 +18,13 @@ import coil3.svg.SvgDecoder
import coil3.video.VideoFrameDecoder
import com.raival.compose.file.explorer.coil.apk.ApkFileDecoder
import com.raival.compose.file.explorer.coil.pdf.PdfFileDecoder
-import com.raival.compose.file.explorer.common.extension.emptyString
-import com.raival.compose.file.explorer.common.extension.printFullStackTrace
-import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.common.logger.FileExplorerLogger
import com.raival.compose.file.explorer.screen.main.MainActivityManager
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTabManager
import com.raival.compose.file.explorer.screen.main.tab.files.coil.DocumentFileMapper
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.manager.FilesTabManager
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.task.TaskManager
+import com.raival.compose.file.explorer.screen.main.tab.files.zip.ZipManager
import com.raival.compose.file.explorer.screen.preferences.PreferencesManager
import com.raival.compose.file.explorer.screen.textEditor.TextEditorManager
import com.raival.compose.file.explorer.screen.viewer.ViewersManager
@@ -36,6 +35,7 @@ import io.github.rosemoe.sora.langs.textmate.registry.model.ThemeModel
import io.github.rosemoe.sora.langs.textmate.registry.provider.AssetsFileResolver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.eclipse.tm4e.core.registry.IThemeSource
import java.io.File
@@ -47,24 +47,23 @@ class App : Application(), coil3.SingletonImageLoader.Factory {
val globalClass
get() = appContext as App
+
+ val logger
+ get() = globalClass.logger
}
- val appFiles: DocumentHolder
- get() = DocumentHolder.fromFile(
- File(globalClass.cacheDir, "files").apply {
- if (!exists()) mkdirs()
- }
- )
+ private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- private val errorLogFile: DocumentHolder
- get() = "logs.txt".let {
- DocumentHolder.fromFile(File(globalClass.cacheDir, it).apply {
- if (!exists()) createNewFile()
- })
- }
+ lateinit var logger: FileExplorerLogger
+ private set
- val recycleBinDir: DocumentHolder
- get() = DocumentHolder.fromFile(File(getExternalFilesDir(null), "bin").apply { mkdirs() })
+ val appFiles: LocalFileHolder
+ get() = LocalFileHolder(
+ File(globalClass.cacheDir, "files").apply { if (!exists()) mkdirs() }
+ )
+
+ val recycleBinDir: LocalFileHolder
+ get() = LocalFileHolder(File(getExternalFilesDir(null), "bin").apply { mkdirs() })
private var uid = 0
@@ -72,22 +71,32 @@ class App : Application(), coil3.SingletonImageLoader.Factory {
val mainActivityManager: MainActivityManager by lazy { MainActivityManager().also { it.setupTabs() } }
val filesTabManager: FilesTabManager by lazy { FilesTabManager() }
val preferencesManager: PreferencesManager by lazy { PreferencesManager() }
- val viewersManager: ViewersManager by lazy { ViewersManager() }
+ val viewersManager: ViewersManager by lazy {
+ setupTextMate()
+ ViewersManager()
+ }
+ val taskManager: TaskManager by lazy { TaskManager() }
+ val zipManager: ZipManager by lazy { ZipManager() }
override fun onCreate() {
- Thread.setDefaultUncaughtExceptionHandler { _: Thread?, throwable: Throwable? ->
- throwable?.let {
- Log.e("AppCrash", emptyString, it).also { log(throwable) }
- }
- Process.killProcess(Process.myPid())
- exitProcess(2)
- }
-
super.onCreate()
+ logger = FileExplorerLogger(this, applicationScope)
+ setupGlobalExceptionHandler()
+
appContext = this
}
+ private fun setupGlobalExceptionHandler() {
+ val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, exception ->
+ logger.logError(exception)
+ defaultHandler?.uncaughtException(thread, exception)
+ Process.killProcess(Process.myPid())
+ exitProcess(2)
+ }
+ }
+
private fun setupTextMate() {
CoroutineScope(Dispatchers.IO).launch {
FileProviderRegistry.getInstance().addFileProvider(
@@ -139,33 +148,6 @@ class App : Application(), coil3.SingletonImageLoader.Factory {
fun generateUid() = uid++
- fun log(throwable: Throwable) {
- log(throwable.printFullStackTrace(), "Error")
- }
-
- fun log(msg: String, header: String = emptyString) {
- if (!errorLogFile.exists()) return
- if (errorLogFile.isFolder) return
-
- if (errorLogFile.fileSize > 1024 * 100) {
- errorLogFile.writeText(emptyString)
- }
-
- errorLogFile.appendText(
- buildString {
- if (header.isNotEmpty()) {
- append(System.lineSeparator().repeat(2))
- append("-".repeat(4))
- append(" $header: ${System.currentTimeMillis().toFormattedDate()} ")
- append("-".repeat(4))
- append(System.lineSeparator())
- }
- append(msg)
- append(System.lineSeparator())
- }
- )
- }
-
override fun newImageLoader(context: PlatformContext): ImageLoader {
return ImageLoader(context)
.newBuilder()
diff --git a/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt
index e881d3b7..9050828b 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/base/BaseActivity.kt
@@ -7,12 +7,44 @@ import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
+import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Folder
+import androidx.compose.material.icons.outlined.FolderOpen
+import androidx.compose.material.icons.outlined.Save
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.theme.FileExplorerTheme
abstract class BaseActivity : AppCompatActivity() {
@@ -31,8 +63,18 @@ abstract class BaseActivity : AppCompatActivity() {
open fun onPermissionGranted() {}
protected fun checkPermissions() {
- if (grantStoragePermissions()) {
+ if (canAccessStorage()) {
onPermissionGranted()
+ return
+ } else {
+ setContent {
+ FileExplorerTheme {
+ StoragePermissionScreen(
+ onGrantPermission = { grantStoragePermissions() },
+ onSkip = { finish() }
+ )
+ }
+ }
}
}
@@ -81,4 +123,156 @@ abstract class BaseActivity : AppCompatActivity() {
onPermissionGranted()
}
}
+
+ @Composable
+ fun StoragePermissionScreen(
+ onGrantPermission: () -> Unit = {},
+ onSkip: () -> Unit = {}
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.weight(1f))
+
+ Icon(
+ imageVector = Icons.Outlined.FolderOpen,
+ contentDescription = null,
+ modifier = Modifier
+ .size(80.dp)
+ .padding(bottom = 24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Text(
+ text = stringResource(R.string.storage_access_required),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.storage_access_description),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ lineHeight = 24.sp,
+ modifier = Modifier.padding(bottom = 32.dp)
+ )
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 32.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp)
+ ) {
+ PermissionFeatureItem(
+ icon = Icons.Outlined.Save,
+ title = stringResource(R.string.save_files),
+ description = stringResource(R.string.save_and_organize_your_documents)
+ )
+
+ PermissionFeatureItem(
+ icon = Icons.Outlined.Folder,
+ title = stringResource(R.string.access_folders),
+ description = stringResource(R.string.browse_and_manage_your_file_structure),
+ showDivider = false
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Button(
+ onClick = onGrantPermission,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.grant_access),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+
+ TextButton(
+ onClick = onSkip,
+ modifier = Modifier.padding(top = 8.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.skip_for_now),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ @Composable
+ private fun PermissionFeatureItem(
+ icon: ImageVector,
+ title: String,
+ description: String,
+ showDivider: Boolean = true
+ ) {
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 2.dp)
+ )
+ }
+ }
+
+ if (showDivider) {
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 40.dp),
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)
+ )
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt b/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt
index 8410f2aa..7f6d1828 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/coil/pdf/PdfFileDecoder.kt
@@ -1,10 +1,10 @@
package com.raival.compose.file.explorer.coil.pdf
-import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.pdf.PdfRenderer
import android.os.ParcelFileDescriptor
+import androidx.core.graphics.createBitmap
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DecodeResult
@@ -16,15 +16,15 @@ import java.io.File
class PdfFileDecoder(val source: File) : Decoder {
override suspend fun decode(): DecodeResult? {
+ //TODO: large pages causes lags?
ParcelFileDescriptor.open(source, ParcelFileDescriptor.MODE_READ_ONLY)
?.use { fileDescriptor ->
PdfRenderer(fileDescriptor).use { pdfRenderer ->
pdfRenderer.openPage(0).use { page ->
val resolutionMultiplier = 0.75f
- val bitmap = Bitmap.createBitmap(
+ val bitmap = createBitmap(
(page.width * resolutionMultiplier).toInt(),
- (page.height * resolutionMultiplier).toInt(),
- Bitmap.Config.ARGB_8888
+ (page.height * resolutionMultiplier).toInt()
).also { Canvas(it).drawColor(Color.WHITE) }
page.render(bitmap, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
return DecodeResult(bitmap.asImage(), false)
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt
index 51e12978..9a8be505 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/CodeEditorExt.kt
@@ -23,133 +23,52 @@ fun CodeEditor.setContent(content: Content, fileInstance: TextEditorManager.File
}
}
-fun CodeEditor.moveSelectionBy(step: Int, controlledCursor: Int) {
- val maxLines = lineCount
-
- val currentRightLine = cursor.rightLine
- val currentRightColumn = cursor.rightColumn
- val maxColumnInCurrentRightLine = text.getColumnCount(currentRightLine)
- val canMoveRightCursorToRight = currentRightColumn < maxColumnInCurrentRightLine
- val canMoveRightCursorToLeft = currentRightColumn > 0
- val canMoveRightCursorToNextLine = currentRightLine < maxLines - 1
- val canMoveRightCursorToPreviousLine = currentRightLine > 0
-
- val currentLeftLine = cursor.leftLine
- val currentLeftColumn = cursor.leftColumn
- val maxColumnInCurrentLeftLine = text.getColumnCount(currentLeftLine)
- val canMoveLeftCursorToLeft = currentLeftColumn > 0
- val canMoveLeftCursorToPreviousLine = currentLeftLine > 0
- val canMoveLeftCursorToNextLine = currentLeftLine < maxLines - 1
- val canMoveLeftCursorToRight = currentLeftColumn < maxColumnInCurrentLeftLine
+private data class Position(val line: Int, val column: Int)
- val rightCursorColumn = if (controlledCursor > 0) {
- if (step > 0) {
- if (canMoveRightCursorToRight) {
- currentRightColumn + step
- } else {
- if (canMoveRightCursorToNextLine) {
- 0
- } else {
- currentRightColumn
- }
- }
+private fun CodeEditor.calculateNewCursorPosition(
+ line: Int,
+ column: Int,
+ step: Int
+): Position {
+ if (step > 0) { // Moving forward
+ val maxColumn = text.getColumnCount(line)
+ return if (column < maxColumn) {
+ Position(line, column + step)
+ } else if (line < lineCount - 1) {
+ Position(line + 1, 0)
} else {
- if (canMoveRightCursorToLeft) {
- currentRightColumn + step
- } else {
- if (canMoveRightCursorToPreviousLine) {
- text.getColumnCount(currentRightLine + step)
- } else {
- 0
- }
- }
+ Position(line, column)
}
- } else {
- currentRightColumn
- }
-
- val leftCursorColumn = if (controlledCursor < 0) {
- if (step > 0) {
- if (canMoveLeftCursorToRight) {
- currentLeftColumn + step
- } else {
- if (canMoveLeftCursorToNextLine) {
- 0
- } else {
- currentLeftColumn
- }
- }
+ } else { // Moving backward
+ return if (column > 0) {
+ Position(line, column + step)
+ } else if (line > 0) {
+ val prevLine = line - 1
+ Position(prevLine, text.getColumnCount(prevLine))
} else {
- if (canMoveLeftCursorToLeft) {
- currentLeftColumn + step
- } else {
- if (canMoveLeftCursorToPreviousLine) {
- text.getColumnCount(currentLeftLine + step)
- } else {
- 0
- }
- }
+ Position(line, column)
}
- } else {
- currentLeftColumn
}
+}
- val rightCursorLine = if (controlledCursor > 0) {
- if (step > 0) {
- if (canMoveRightCursorToRight) {
- currentRightLine
- } else {
- if (canMoveRightCursorToNextLine) {
- currentRightLine + 1
- } else {
- currentRightLine
- }
- }
- } else {
- if (canMoveRightCursorToLeft) {
- currentRightLine
- } else {
- if (canMoveRightCursorToPreviousLine) {
- currentRightLine - 1
- } else {
- currentRightLine
- }
- }
+fun CodeEditor.moveSelectionBy(step: Int, controlledCursor: Int) {
+ var rightPos = Position(cursor.rightLine, cursor.rightColumn)
+ var leftPos = Position(cursor.leftLine, cursor.leftColumn)
+
+ when {
+ controlledCursor > 0 -> {
+ rightPos = calculateNewCursorPosition(rightPos.line, rightPos.column, step)
}
- } else {
- currentRightLine
- }
- val leftCursorLine = if (controlledCursor < 0) {
- if (step > 0) {
- if (canMoveLeftCursorToRight) {
- currentLeftLine
- } else {
- if (canMoveLeftCursorToNextLine) {
- currentLeftLine + 1
- } else {
- currentLeftLine
- }
- }
- } else {
- if (canMoveLeftCursorToLeft) {
- currentLeftLine
- } else {
- if (canMoveLeftCursorToPreviousLine) {
- currentLeftLine - 1
- } else {
- currentLeftLine
- }
- }
+ controlledCursor < 0 -> {
+ leftPos = calculateNewCursorPosition(leftPos.line, leftPos.column, step)
}
- } else {
- currentLeftLine
}
setSelectionRegion(
- rightCursorLine,
- rightCursorColumn,
- leftCursorLine,
- leftCursorColumn
+ rightPos.line,
+ rightPos.column,
+ leftPos.line,
+ leftPos.column
)
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt
index c17d5165..d02e1326 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/Extensions.kt
@@ -10,12 +10,19 @@ import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import android.net.Uri
+import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns.DISPLAY_NAME
+import android.provider.OpenableColumns
+import android.util.Size
+import android.webkit.MimeTypeMap
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
+import androidx.core.graphics.createBitmap
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType
import java.io.File
import java.io.FileInputStream
+import java.io.FileNotFoundException
import java.io.IOException
import java.io.InputStream
import java.io.PrintWriter
@@ -31,6 +38,9 @@ fun T.conditions(cond: T.() -> Boolean) = this.cond()
@Composable
fun Int.dp() = (this / globalClass.resources.displayMetrics.density).dp
+@Composable
+fun Float.dp() = (this / globalClass.resources.displayMetrics.density).dp
+
val Int.px: Int
get() = (this * globalClass.resources.displayMetrics.density).toInt()
@@ -43,6 +53,58 @@ fun Context.findActivity(): Activity? {
return null
}
+fun File.listFilesAndEmptyDirs(): List {
+ val result = mutableListOf()
+
+ // A queue of directories to visit. Start with the root.
+ val directoryQueue = ArrayDeque()
+ if (isDirectory) {
+ directoryQueue.add(this)
+ } else if (isFile) {
+ // If the starting path is just a file, add it and return.
+ result.add(this)
+ return result
+ } else {
+ // Not a valid file or directory, return an empty list.
+ return emptyList()
+ }
+
+ while (directoryQueue.isNotEmpty()) {
+ val dir = directoryQueue.removeFirst()
+ // listFiles() can be null if the directory is not accessible
+ val children = dir.listFiles() ?: continue
+
+ if (children.isEmpty()) {
+ // This directory is empty, add it to the list.
+ result.add(dir)
+ } else {
+ // Process the contents of the non-empty directory
+ for (child in children) {
+ if (child.isFile) {
+ result.add(child)
+ } else if (child.isDirectory) {
+ directoryQueue.add(child)
+ }
+ }
+ }
+ }
+ return result
+}
+
+fun Uri.getMimeType(context: Context): String {
+ // 1. Try to get the MIME type from the ContentResolver
+ // This is the most reliable way for content:// URIs
+ val mimeType = context.contentResolver.getType(this)
+ if (mimeType != null) {
+ return mimeType
+ }
+
+ // 2. If the ContentResolver fails, fall back to the file extension
+ val fileExtension = MimeTypeMap.getFileExtensionFromUrl(this.toString())
+ return MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension?.lowercase())
+ ?: anyFileType
+}
+
val Uri.name: String?
get() {
return when (scheme) {
@@ -57,11 +119,179 @@ val Uri.name: String?
}
}
}
- "file" -> { File(path ?: "").name }
+
+ "file" -> {
+ File(path ?: emptyString).name
+ }
+
else -> null
}
}
+data class UriInfo(
+ val uri: Uri,
+ val name: String?,
+ val size: Long?,
+ val mimeType: String?,
+ val lastModified: Long?,
+ val extension: String?,
+ val path: String?
+)
+
+fun Uri.lastModified(context: Context): Long {
+ return when (scheme) {
+ "content" -> {
+ val cursor = context.contentResolver.query(this, null, null, null, null)
+ cursor?.use {
+ if (it.moveToFirst()) {
+ val lastModifiedIndex = it.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
+ if (lastModifiedIndex != -1) {
+ it.getLong(lastModifiedIndex) * 1000L
+ } else {
+ 0L
+ }
+ } else {
+ 0L
+ }
+ } ?: 0L
+ }
+
+ "file" -> {
+ path?.let { File(it).lastModified() } ?: 0L
+ }
+
+ else -> 0L
+ }
+}
+
+fun Uri.getUriInfo(context: Context): UriInfo {
+ val contentResolver = context.contentResolver
+
+ var name: String? = null
+ var size: Long? = null
+ var lastModified: Long? = null
+ var path: String? = null
+
+ if (scheme.equals("content", ignoreCase = true)) {
+ // This is a content URI. Use ContentResolver to query metadata.
+ val cursor = contentResolver.query(this, null, null, null, null)
+ cursor?.use {
+ if (it.moveToFirst()) {
+ // Get Display Name
+ val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ if (nameIndex != -1) {
+ name = it.getString(nameIndex)
+ }
+
+ // Get Size
+ val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
+ if (sizeIndex != -1) {
+ size = it.getLong(sizeIndex)
+ }
+
+ // Get Last Modified (might not be available for all providers)
+ val lastModifiedIndex = it.getColumnIndex(MediaStore.MediaColumns.DATE_MODIFIED)
+ if (lastModifiedIndex != -1) {
+ // The value is in seconds, convert to milliseconds
+ lastModified = it.getLong(lastModifiedIndex) * 1000L
+ }
+
+ // Try to get a real path. WARNING: This is not reliable and often returns null.
+ // It's most likely to work for MediaStore URIs.
+ try {
+ val pathIndex = it.getColumnIndex(MediaStore.Images.Media.DATA)
+ if (pathIndex != -1) {
+ path = it.getString(pathIndex)
+ }
+ } catch (e: Exception) {
+ // The DATA column might not exist for this URI, which is normal.
+ path = null
+ }
+ }
+ }
+ } else if (scheme.equals("file", ignoreCase = true)) {
+ // This is a file URI.
+ this.path?.let { filePath ->
+ val file = File(filePath)
+ if (file.exists()) {
+ name = file.name
+ size = file.length()
+ lastModified = file.lastModified()
+ path = file.absolutePath
+ }
+ }
+ }
+
+ // Get MIME type from ContentResolver, which is more reliable.
+ val mimeType = contentResolver.getType(this)
+
+ // Get extension from the file name.
+ val extension = name?.substringAfterLast('.', "")
+
+ return UriInfo(
+ uri = this,
+ name = name,
+ size = size,
+ mimeType = mimeType,
+ lastModified = lastModified,
+ extension = extension,
+ path = path
+ )
+}
+
+fun Uri.exists(context: Context): Boolean {
+ // 1. Handle `file://` URIs
+ if (scheme == "file") {
+ return path?.let { File(it).exists() } ?: false
+ }
+
+ // 2. Handle `content://` URIs
+ if (scheme == "content") {
+ try {
+ // Use openFileDescriptor for a lightweight check.
+ // "r" specifies read-only access.
+ context.contentResolver.openFileDescriptor(this, "r")?.use {
+ // If we get here, the file descriptor was opened successfully,
+ // which means the URI is valid and accessible.
+ return true
+ }
+ } catch (_: FileNotFoundException) {
+ // The content provider reported that the file doesn't exist.
+ return false
+ } catch (_: SecurityException) {
+ // We don't have permission to read the URI.
+ return false
+ } catch (_: Exception) {
+ // Handle other potential exceptions, such as IllegalArgumentException for a malformed URI.
+ return false
+ }
+ }
+
+ // 3. For other schemes or if all checks fail
+ return false
+}
+
+fun String.asCodeEditorCursorCoordinates(): Pair {
+ val trimmedInput = trim()
+ return when {
+ trimmedInput.matches(Regex("\\d+")) -> Pair(trimmedInput.toInt(), 0)
+ trimmedInput.matches(Regex("\\d+:\\d+")) -> {
+ val parts = trimmedInput.split(":").map { it.trim().toInt() }
+ Pair(parts[0], parts[1])
+ }
+
+ else -> Pair(-1, -1)
+ }
+}
+
+fun Size.isZero() = width == 0 || height == 0
+
+fun Bitmap.scale(scaleFactor: Float, filter: Boolean = false): Bitmap {
+ val newWidth = (width * scaleFactor).toInt()
+ val newHeight = (height * scaleFactor).toInt()
+ return Bitmap.createScaledBitmap(this, newWidth, newHeight, filter)
+}
+
fun Throwable.printFullStackTrace(): String {
val result: Writer = StringWriter()
val printWriter = PrintWriter(result)
@@ -73,6 +303,14 @@ fun Throwable.printFullStackTrace(): String {
infix fun Any?.isNot(value: Any?) = this != value
+fun Int.isMultipleOf100(): Boolean {
+ return this % 100 == 0
+}
+
+fun showMsg(msg: String) {
+ globalClass.showMsg(msg)
+}
+
@SuppressLint("SimpleDateFormat")
fun Long.toFormattedDate(): String = SimpleDateFormat("MMM dd, hh:mm a").format(this)
@@ -85,6 +323,19 @@ fun Long.toFormattedSize(): String {
) + " " + units[digitGroups]
}
+fun Long.toFormattedTime(): String {
+ val totalSeconds = this / 1000
+ val hours = totalSeconds / 3600
+ val minutes = (totalSeconds % 3600) / 60
+ val seconds = totalSeconds % 60
+
+ return if (hours > 0) {
+ "%02d:%02d:%02d".format(hours, minutes, seconds)
+ } else {
+ "%02d:%02d".format(minutes, seconds)
+ }
+}
+
fun Context.isDarkTheme() = (resources.configuration.uiMode
and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
@@ -96,7 +347,6 @@ fun Uri.read(): ByteArray {
"file" -> FileInputStream(this.path?.let { File(it) })
else -> null
}
-
inputStream?.use { stream -> return stream.readBytes() } ?: return ByteArray(0)
}
@@ -104,7 +354,7 @@ fun Drawable.drawableToBitmap(): Bitmap? {
return if (this is BitmapDrawable) {
bitmap
} else {
- val bitmap = Bitmap.createBitmap(intrinsicWidth, intrinsicHeight, Bitmap.Config.ARGB_8888)
+ val bitmap = createBitmap(intrinsicWidth, intrinsicHeight)
val canvas = Canvas(bitmap)
setBounds(0, 0, canvas.width, canvas.height)
draw(canvas)
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt
index e3bdbb40..4841d324 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/ListExt.kt
@@ -1,25 +1,37 @@
package com.raival.compose.file.explorer.common.extension
fun ArrayList.addIf(item: T, condition: T.() -> Boolean) {
- if (condition(item)) { add(item) }
+ if (condition(item)) {
+ add(item)
+ }
}
fun List.getIf(condition: T.() -> Boolean): T? {
forEach {
- if (condition(it)) { return it }
+ if (condition(it)) {
+ return it
+ }
}
return null
}
fun List.getIndexIf(condition: T.() -> Boolean): Int {
forEachIndexed { index, item ->
- if (condition(item)) { return index }
+ if (condition(item)) {
+ return index
+ }
}
return -1
}
-fun ArrayList.addIfAbsent(toAdd: String) { if (!contains(toAdd)) add(toAdd) }
+fun ArrayList.addIfAbsent(toAdd: String) {
+ if (!contains(toAdd)) add(toAdd)
+}
fun HashMap.removeIf(condition: (A, B) -> Boolean) {
- keys.forEach { if (condition(it, this[it]!!)) { remove(it) } }
+ keys.forEach {
+ if (condition(it, this[it]!!)) {
+ remove(it)
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt b/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt
index 37311db4..d5bcebce 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/extension/StringExt.kt
@@ -3,7 +3,9 @@ package com.raival.compose.file.explorer.common.extension
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
+import com.anggrayudi.storage.extension.trimFileSeparator
import com.raival.compose.file.explorer.App
+import java.io.File
import kotlin.random.Random
const val emptyString = ""
@@ -17,11 +19,20 @@ fun String.Companion.randomString(length: Int): String {
.joinToString(emptyString)
}
+fun String.hasParent(parentPath: String): Boolean {
+ val parentTree = parentPath.getFolderTree()
+ val subFolderTree = getFolderTree()
+ return parentTree.size <= subFolderTree.size && subFolderTree.take(parentTree.size) == parentTree
+}
+
+private fun String.getFolderTree() =
+ split('/').map { it.trimFileSeparator() }.filter { it.isNotEmpty() }
+
fun String.orIf(value: String, condition: (String) -> Boolean) =
if (condition(this)) value else this
fun String.isValidAsFileName() = !conditions {
- contains(":") || contains("/") || contains("*") || contains("?")
+ contains(":") || contains(File.separator) || contains("*") || contains("?")
|| contains("\"") || contains("<") || contains(">")
|| contains("|")
} && isNotBlank()
@@ -43,15 +54,51 @@ fun String.copyToClipboard() {
}
fun String.trimToLastTwoSegments(): String {
- val segments = this.split("/")
+ val segments = this.split(File.separator)
return if (segments.size >= 2) {
- segments.takeLast(2).joinToString("/")
+ segments.takeLast(2).joinToString(File.separator)
} else {
this
}
}
+/**
+ * Calculates the relative path of this string path with respect to a given [base] path string.
+ * This is a string-based reimplementation of [File.toRelativeString].
+ *
+ * @param base The base path string to which the path should be made relative.
+ * @return The relative path string, or an empty string if the paths are the same.
+ * @throws IllegalArgumentException if this path does not start with the [base] path.
+ */
+fun String.toRelativeString(base: String): String {
+ // 1. Normalize both paths for consistent comparison
+ // - Replace backslashes with forward slashes
+ // - Remove any trailing slashes
+ val thisPath = this.replace('\\', '/').trimEnd('/')
+ val basePath = base.replace('\\', '/').trimEnd('/')
+
+ // 2. Handle the case where the paths are identical
+ if (thisPath == basePath) {
+ return emptyString
+ }
+
+ // 3. The base path must be a true prefix.
+ // We append a slash to the base path to ensure we're matching a full directory segment.
+ // This prevents "/a/b_c" from being considered relative to "/a/b".
+ // If the base path is empty, we don't add a leading slash.
+ val basePrefix = if (basePath.isEmpty()) emptyString else "$basePath/"
+
+ if (!thisPath.startsWith(basePrefix)) {
+ return thisPath
+ }
+
+ // 4. The relative path is the part of this path that comes after the base prefix.
+ return thisPath.substring(basePrefix.length)
+}
+
fun String.limitLength(maxLength: Int): String {
- if (this.length <= maxLength) { return this }
+ if (this.length <= maxLength) {
+ return this
+ }
return this.substring(0, maxLength - 3) + "..."
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/logger/FileExplorerLogger.kt b/app/src/main/java/com/raival/compose/file/explorer/common/logger/FileExplorerLogger.kt
new file mode 100644
index 00000000..126ea470
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/logger/FileExplorerLogger.kt
@@ -0,0 +1,306 @@
+package com.raival.compose.file.explorer.common.logger
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.google.gson.Gson
+import com.raival.compose.file.explorer.common.extension.printFullStackTrace
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.UUID
+
+private val Context.logsDataStore: DataStore by preferencesDataStore(name = "logs")
+
+data class LogHolder(
+ val message: String,
+ val timestamp: String,
+ val id: String = UUID.randomUUID().toString()
+)
+
+class FileExplorerLogger(private val context: Context, private val scope: CoroutineScope) {
+
+ companion object {
+ private val ERRORS_KEY = stringPreferencesKey("error_logs")
+ private val WARNINGS_KEY = stringPreferencesKey("warning_logs")
+ private val INFOS_KEY = stringPreferencesKey("info_logs")
+ private const val MAX_ERRORS = 5
+ private const val MAX_WARNINGS = 10
+ private const val MAX_INFOS = 30
+ }
+
+ private val gson = Gson()
+ private val logListType = object : com.google.gson.reflect.TypeToken>() {}.type
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
+
+ // ERROR LOGGING FUNCTIONS
+ /**
+ * Log a string error message
+ */
+ fun logError(message: String) {
+ val errorLog = LogHolder(
+ message = message,
+ timestamp = dateFormat.format(Date())
+ )
+ scope.launch {
+ saveError(errorLog)
+ }
+ }
+
+ /**
+ * Log an exception
+ */
+ fun logError(exception: Throwable) {
+ val errorLog = LogHolder(
+ message = exception.stackTraceToString(),
+ timestamp = dateFormat.format(Date())
+ )
+ scope.launch {
+ saveError(errorLog)
+ }
+ }
+
+ /**
+ * Save error to DataStore, maintaining only the 5 most recent
+ */
+ private suspend fun saveError(newError: LogHolder) {
+ context.logsDataStore.edit { preferences ->
+ val currentErrorsJson = preferences[ERRORS_KEY] ?: "[]"
+ val currentErrors = try {
+ gson.fromJson>(currentErrorsJson, logListType) ?: emptyList()
+ } catch (_: Exception) {
+ emptyList()
+ }
+
+ // Add new error at the beginning and keep only the most recent 5
+ val updatedErrors = (listOf(newError) + currentErrors).reversed().takeLast(MAX_ERRORS)
+
+ preferences[ERRORS_KEY] = gson.toJson(updatedErrors)
+ }
+ }
+
+ /**
+ * Get all stored errors as a Flow
+ */
+ fun getErrors(): Flow> {
+ return context.logsDataStore.data.map { preferences ->
+ val errorsJson = preferences[ERRORS_KEY] ?: "[]"
+ try {
+ gson.fromJson>(errorsJson, logListType) ?: emptyList()
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+ }
+
+ /**
+ * Clear all stored errors
+ */
+ suspend fun clearErrors() {
+ context.logsDataStore.edit { preferences ->
+ preferences.remove(ERRORS_KEY)
+ }
+ }
+
+ /**
+ * Get the most recent error
+ */
+ fun getLatestError(): Flow {
+ return getErrors().map { errors ->
+ errors.firstOrNull()
+ }
+ }
+
+ // WARNING LOGGING FUNCTIONS
+ /**
+ * Log a string warning message
+ */
+ fun logWarning(message: String) {
+ val warningLog = LogHolder(
+ message = message,
+ timestamp = dateFormat.format(Date())
+ )
+ scope.launch {
+ saveWarning(warningLog)
+ }
+ }
+
+ /**
+ * Log an exception as warning
+ */
+ fun logWarning(exception: Throwable) {
+ val warningLog = LogHolder(
+ message = exception.printFullStackTrace(),
+ timestamp = dateFormat.format(Date())
+ )
+ scope.launch {
+ saveWarning(warningLog)
+ }
+ }
+
+ /**
+ * Save warning to DataStore, maintaining only the 10 most recent
+ */
+ private suspend fun saveWarning(newWarning: LogHolder) {
+ context.logsDataStore.edit { preferences ->
+ val currentWarningsJson = preferences[WARNINGS_KEY] ?: "[]"
+ val currentWarnings = try {
+ gson.fromJson>(currentWarningsJson, logListType) ?: emptyList()
+ } catch (_: Exception) {
+ emptyList()
+ }
+
+ // Add new warning at the beginning and keep only the most recent 10
+ val updatedWarnings =
+ (listOf(newWarning) + currentWarnings).reversed().takeLast(MAX_WARNINGS)
+
+ preferences[WARNINGS_KEY] = gson.toJson(updatedWarnings)
+ }
+ }
+
+ /**
+ * Get all stored warnings as a Flow
+ */
+ fun getWarnings(): Flow> {
+ return context.logsDataStore.data.map { preferences ->
+ val warningsJson = preferences[WARNINGS_KEY] ?: "[]"
+ try {
+ gson.fromJson>(warningsJson, logListType) ?: emptyList()
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+ }
+
+ /**
+ * Clear all stored warnings
+ */
+ suspend fun clearWarnings() {
+ context.logsDataStore.edit { preferences ->
+ preferences.remove(WARNINGS_KEY)
+ }
+ }
+
+ /**
+ * Get the most recent warning
+ */
+ fun getLatestWarning(): Flow {
+ return getWarnings().map { warnings ->
+ warnings.firstOrNull()
+ }
+ }
+
+ // INFO LOGGING FUNCTIONS
+ /**
+ * Log a string info message
+ */
+ fun logInfo(message: String) {
+ val infoLog = LogHolder(
+ message = message,
+ timestamp = dateFormat.format(Date())
+ )
+ scope.launch {
+ saveInfo(infoLog)
+ }
+ }
+
+ /**
+ * Save info to DataStore, maintaining only the 30 most recent
+ */
+ private suspend fun saveInfo(newInfo: LogHolder) {
+ context.logsDataStore.edit { preferences ->
+ val currentInfosJson = preferences[INFOS_KEY] ?: "[]"
+ val currentInfos = try {
+ gson.fromJson>(currentInfosJson, logListType) ?: emptyList()
+ } catch (_: Exception) {
+ emptyList()
+ }
+
+ // Add new info at the beginning and keep only the most recent 30
+ val updatedInfos = (listOf(newInfo) + currentInfos).reversed().takeLast(MAX_INFOS)
+
+ preferences[INFOS_KEY] = gson.toJson(updatedInfos)
+ }
+ }
+
+ /**
+ * Get all stored infos as a Flow
+ */
+ fun getInfos(): Flow> {
+ return context.logsDataStore.data.map { preferences ->
+ val infosJson = preferences[INFOS_KEY] ?: "[]"
+ try {
+ gson.fromJson>(infosJson, logListType) ?: emptyList()
+ } catch (_: Exception) {
+ emptyList()
+ }
+ }
+ }
+
+ /**
+ * Clear all stored infos
+ */
+ suspend fun clearInfos() {
+ context.logsDataStore.edit { preferences ->
+ preferences.remove(INFOS_KEY)
+ }
+ }
+
+ /**
+ * Get the most recent info
+ */
+ fun getLatestInfo(): Flow {
+ return getInfos().map { infos ->
+ infos.firstOrNull()
+ }
+ }
+
+ // UTILITY FUNCTIONS
+ /**
+ * Clear all logs (errors, warnings, and infos)
+ */
+ suspend fun clearAllLogs() {
+ context.logsDataStore.edit { preferences ->
+ preferences.remove(ERRORS_KEY)
+ preferences.remove(WARNINGS_KEY)
+ preferences.remove(INFOS_KEY)
+ }
+ }
+
+ /**
+ * Get total count of all logs
+ */
+ fun getTotalLogCount(): Flow {
+ return context.logsDataStore.data.map { preferences ->
+ val errorCount = try {
+ val errorsJson = preferences[ERRORS_KEY] ?: "[]"
+ gson.fromJson>(errorsJson, logListType)?.size ?: 0
+ } catch (_: Exception) {
+ 0
+ }
+
+ val warningCount = try {
+ val warningsJson = preferences[WARNINGS_KEY] ?: "[]"
+ gson.fromJson>(warningsJson, logListType)?.size ?: 0
+ } catch (_: Exception) {
+ 0
+ }
+
+ val infoCount = try {
+ val infosJson = preferences[INFOS_KEY] ?: "[]"
+ gson.fromJson>(infosJson, logListType)?.size ?: 0
+ } catch (_: Exception) {
+ 0
+ }
+
+ errorCount + warningCount + infoCount
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt
index 66ca640b..9a6e2d25 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/CheckableText.kt
@@ -40,7 +40,7 @@ fun CheckableText(
checkedBoxBackgroundColor: Color = MaterialTheme.colorScheme.primary,
uncheckedBoxBackgroundColor: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.5.dp),
boxSize: Dp = 21.dp,
- strokeWidth: Dp = 1.dp,
+ strokeWidth: Dp = 0.5.dp,
checkIcon: ImageVector = Icons.Rounded.Done,
checkIconTint: Color = MaterialTheme.colorScheme.surface,
text: @Composable () -> Unit
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt
index e23fc619..4758dd7e 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Compose.kt
@@ -45,12 +45,11 @@ fun Modifier.detectVerticalSwipe(
}
fun Modifier.block(
- shape: Shape = RoundedCornerShape(12.dp),
+ shape: Shape = RoundedCornerShape(16.dp),
color: Color = Color.Unspecified,
- applyResultPadding: Boolean = false,
- resultPadding: Dp = 4.dp,
+ padding: Dp = 0.dp,
borderColor: Color = Color.Unspecified,
- borderSize: Dp = 1.dp
+ borderSize: Dp = 0.dp
) = composed {
val color1 = if (color.isUnspecified) {
MaterialTheme.colorScheme.surfaceContainerHigh
@@ -67,7 +66,7 @@ fun Modifier.block(
shape = shape
)
)
- .then(if (applyResultPadding) Modifier.padding(resultPadding) else Modifier)
+ .padding(padding)
}
fun ColorScheme.outlineVariant(
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DynamicSelectTextField.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/DynamicSelectTextField.kt
similarity index 97%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DynamicSelectTextField.kt
rename to app/src/main/java/com/raival/compose/file/explorer/common/ui/DynamicSelectTextField.kt
index fc180ce8..90d73b5e 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DynamicSelectTextField.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/DynamicSelectTextField.kt
@@ -1,4 +1,4 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
+package com.raival.compose.file.explorer.common.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.DropdownMenuItem
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/InputDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/InputDialog.kt
deleted file mode 100644
index fbf1f4ec..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/InputDialog.kt
+++ /dev/null
@@ -1,84 +0,0 @@
-package com.raival.compose.file.explorer.common.ui
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.Text
-import androidx.compose.material3.TextField
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.window.Dialog
-import com.raival.compose.file.explorer.common.extension.emptyString
-
-data class InputDialogInput(
- val label: String,
- var content: String = emptyString,
- val onValidate: ((content: String) -> String?)? = null
-)
-
-data class InputDialogButton(
- val label: String,
- val onClick: (inputs: ArrayList) -> Unit
-)
-
-@Composable
-fun InputDialog(
- title: String,
- inputs: ArrayList,
- buttons: ArrayList,
- onDismiss: () -> Unit
-) {
- Dialog(
- onDismissRequest = { onDismiss() }
- ) {
- Column(
- Modifier
- .fillMaxWidth()
- .block()
- .padding(24.dp)) {
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = title,
- fontSize = 18.sp
- )
-
- inputs.forEach { input ->
- Space(size = 16.dp)
-
- var text by remember { mutableStateOf(input.content) }
- var error by remember { mutableStateOf(null) }
-
- TextField(
- modifier = Modifier.fillMaxWidth(),
- value = text,
- onValueChange = {
- text = it
- input.content = it
- error = input.onValidate?.invoke(it)
- },
- label = { Text(text = input.label) },
- isError = error != null && error?.isNotEmpty() ?: false,
- supportingText = { error?.let { Text(text = it) } }
- )
- }
-
- buttons.forEach { button ->
- Space(size = 8.dp)
-
- Button(
- modifier = Modifier.fillMaxWidth(),
- onClick = { button.onClick(inputs) }
- ) {
- Text(text = button.label)
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt
index c5e39f18..7c75586e 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Isolate.kt
@@ -3,4 +3,6 @@ package com.raival.compose.file.explorer.common.ui
import androidx.compose.runtime.Composable
@Composable
-fun Isolate(content: @Composable () -> Unit) { content() }
\ No newline at end of file
+fun Isolate(content: @Composable () -> Unit) {
+ content()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt
index e22fe945..14401ea1 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/SafeSurface.kt
@@ -18,6 +18,7 @@ import androidx.compose.ui.Modifier
@Composable
fun SafeSurface(
+ enableStatusBarsPadding: Boolean = true,
content: @Composable () -> Unit
) {
Surface(
@@ -28,13 +29,15 @@ fun SafeSurface(
color = colorScheme.surfaceContainerLowest
) {
Column(Modifier.fillMaxSize()) {
- Row(
- Modifier
- .fillMaxWidth()
- .wrapContentHeight()
- .background(color = colorScheme.surfaceContainer)
- .windowInsetsPadding(WindowInsets.statusBars)
- ) {}
+ if (enableStatusBarsPadding) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .background(color = colorScheme.surfaceContainer)
+ .windowInsetsPadding(WindowInsets.statusBars)
+ ) {}
+ }
content()
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt
index 3a6dfcc6..6a469cb7 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/common/ui/Space.kt
@@ -7,4 +7,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
@Composable
-fun Space(size: Dp) { Spacer(modifier = Modifier.size(size)) }
\ No newline at end of file
+fun Space(size: Dp) {
+ Spacer(modifier = Modifier.size(size))
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/logs/LogsActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/LogsActivity.kt
new file mode 100644
index 00000000..f27dac31
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/LogsActivity.kt
@@ -0,0 +1,24 @@
+package com.raival.compose.file.explorer.screen.logs
+
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import com.raival.compose.file.explorer.base.BaseActivity
+import com.raival.compose.file.explorer.screen.logs.ui.LogsScreen
+import com.raival.compose.file.explorer.theme.FileExplorerTheme
+
+class LogsActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ checkPermissions()
+ }
+
+ override fun onPermissionGranted() {
+ setContent {
+ FileExplorerTheme {
+ LogsScreen { onBackPressedDispatcher.onBackPressed() }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/logs/ui/LogsScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/ui/LogsScreen.kt
new file mode 100644
index 00000000..1e37cc63
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/logs/ui/LogsScreen.kt
@@ -0,0 +1,430 @@
+package com.raival.compose.file.explorer.screen.logs.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.ArrowBack
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.FilterList
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.copyToClipboard
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.logger.LogHolder
+import com.raival.compose.file.explorer.common.ui.Space
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+
+enum class LogType(
+ val displayName: String,
+ val icon: ImageVector,
+ val color: Color
+) {
+ ALL("All", Icons.Filled.FilterList, Color.Gray),
+ ERROR("Errors", Icons.Filled.Error, Color(0xFFD32F2F)),
+ WARNING("Warnings", Icons.Filled.Warning, Color(0xFFF57C00)),
+ INFO("Info", Icons.Filled.Info, Color(0xFF1976D2))
+}
+
+data class CombinedLog(
+ val logHolder: LogHolder,
+ val type: LogType
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LogsScreen(
+ onBackClick: () -> Unit
+) {
+ val scope = rememberCoroutineScope()
+
+ // Collect logs from logger
+ val errors by logger.getErrors().collectAsStateWithLifecycle(initialValue = emptyList())
+ val warnings by logger.getWarnings().collectAsStateWithLifecycle(initialValue = emptyList())
+ val infos by logger.getInfos().collectAsStateWithLifecycle(initialValue = emptyList())
+
+ // UI State
+ var selectedLogType by remember { mutableStateOf(LogType.ALL) }
+ var showClearDialog by remember { mutableStateOf(false) }
+ var isClearing by remember { mutableStateOf(false) }
+
+ // Combine all logs with their types
+ val combinedLogs = remember(errors, warnings, infos) {
+ val allLogs = mutableListOf()
+ errors.forEach { allLogs.add(CombinedLog(it, LogType.ERROR)) }
+ warnings.forEach { allLogs.add(CombinedLog(it, LogType.WARNING)) }
+ infos.forEach { allLogs.add(CombinedLog(it, LogType.INFO)) }
+
+ // Sort by timestamp (newest first)
+ allLogs.sortedByDescending { it.logHolder.timestamp }
+ }
+
+ // Filter logs based on selected type
+ val filteredLogs = remember(combinedLogs, selectedLogType) {
+ when (selectedLogType) {
+ LogType.ALL -> combinedLogs
+ LogType.ERROR -> combinedLogs.filter { it.type == LogType.ERROR }
+ LogType.WARNING -> combinedLogs.filter { it.type == LogType.WARNING }
+ LogType.INFO -> combinedLogs.filter { it.type == LogType.INFO }
+ }
+ }
+
+ // Function to handle clearing logs
+ suspend fun clearSelectedLogs() {
+ isClearing = true
+ try {
+ runBlocking {
+ when (selectedLogType) {
+ LogType.ALL -> logger.clearAllLogs()
+ LogType.ERROR -> logger.clearErrors()
+ LogType.WARNING -> logger.clearWarnings()
+ LogType.INFO -> logger.clearInfos()
+ }
+ }
+ } finally {
+ isClearing = false
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.title_activity_logs),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ },
+ actions = {
+ IconButton(
+ onClick = { showClearDialog = true },
+ enabled = combinedLogs.isNotEmpty()
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.clear_logs)
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surface,
+ titleContentColor = MaterialTheme.colorScheme.onSurface
+ ),
+ navigationIcon = {
+ IconButton(
+ onClick = onBackClick
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
+ contentDescription = null
+ )
+ }
+ }
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ ) {
+ // Filter chips
+ LazyRow(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(LogType.entries.toTypedArray()) { logType ->
+ val count = when (logType) {
+ LogType.ALL -> combinedLogs.size
+ LogType.ERROR -> errors.size
+ LogType.WARNING -> warnings.size
+ LogType.INFO -> infos.size
+ }
+
+ FilterChip(
+ onClick = { selectedLogType = logType },
+ label = {
+ Text("${logType.displayName} ($count)")
+ },
+ selected = selectedLogType == logType,
+ leadingIcon = {
+ Icon(
+ imageVector = logType.icon,
+ contentDescription = null,
+ tint = if (selectedLogType == logType) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ logType.color
+ },
+ modifier = Modifier.size(18.dp)
+ )
+ },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = logType.color.copy(alpha = 0.12f),
+ selectedLabelColor = MaterialTheme.colorScheme.onSurface
+ )
+ )
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ // Logs list
+ if (filteredLogs.isEmpty()) {
+ EmptyLogsState(selectedLogType = selectedLogType)
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(
+ items = filteredLogs,
+ key = { it.logHolder.id }
+ ) { combinedLog ->
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = { },
+ onLongClick = {
+ combinedLog.logHolder.message.copyToClipboard()
+ globalClass.showMsg(R.string.copied_to_clipboard)
+ }
+ )
+ ) {
+ LogItem(
+ combinedLog = combinedLog,
+ modifier = Modifier
+ .animateItem()
+ )
+ Space(8.dp)
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+
+ // Loading overlay
+ if (isClearing) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Card(
+ modifier = Modifier.padding(32.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Row(
+ modifier = Modifier.padding(24.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 3.dp
+ )
+ Text(
+ text = stringResource(R.string.clearing_logs),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Clear dialog
+ if (showClearDialog) {
+ AlertDialog(
+ onDismissRequest = { showClearDialog = false },
+ title = { Text(stringResource(R.string.clear_logs)) },
+ text = {
+ Text(stringResource(R.string.clear_all_logs_warning))
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ scope.launch { clearSelectedLogs() }
+ showClearDialog = false
+ }
+ ) {
+ Text(stringResource(R.string.clear))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showClearDialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun LogItem(
+ combinedLog: CombinedLog,
+ modifier: Modifier = Modifier
+) {
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Log type indicator
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .background(combinedLog.type.color.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = combinedLog.type.icon,
+ contentDescription = combinedLog.type.displayName,
+ tint = combinedLog.type.color,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ // Log content
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = combinedLog.type.displayName,
+ style = MaterialTheme.typography.labelMedium,
+ color = combinedLog.type.color,
+ fontWeight = FontWeight.Medium
+ )
+ Text(
+ text = combinedLog.logHolder.timestamp,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ Text(
+ text = combinedLog.logHolder.message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyLogsState(
+ selectedLogType: LogType,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = selectedLogType.icon,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = when (selectedLogType) {
+ LogType.ALL -> stringResource(R.string.no_logs_available)
+ LogType.ERROR -> stringResource(R.string.no_errors_logged)
+ LogType.WARNING -> stringResource(R.string.no_warnings_logged)
+ LogType.INFO -> stringResource(R.string.no_info_logs_available)
+ },
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Text(
+ text = when (selectedLogType) {
+ LogType.ALL -> emptyString
+ LogType.ERROR -> stringResource(R.string.great_no_errors_have_been_encountered)
+ LogType.WARNING -> stringResource(R.string.no_warnings_have_been_logged)
+ LogType.INFO -> stringResource(R.string.no_information_logs_have_been_recorded)
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt
index 8633df48..aa7810aa 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivity.kt
@@ -18,7 +18,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.base.BaseActivity
import com.raival.compose.file.explorer.common.ui.SafeSurface
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab
import com.raival.compose.file.explorer.screen.main.ui.AppInfoDialog
import com.raival.compose.file.explorer.screen.main.ui.JumpToPathDialog
@@ -57,12 +57,12 @@ class MainActivity : BaseActivity() {
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
if (mainActivityManager.tabs.isNotEmpty())
- mainActivityManager.tabs[mainActivityManager.selectedTabIndex].onTabResumed()
+ mainActivityManager.getActiveTab().onTabResumed()
}
LifecycleEventEffect(Lifecycle.Event.ON_STOP) {
if (mainActivityManager.tabs.isNotEmpty())
- mainActivityManager.tabs[mainActivityManager.selectedTabIndex].onTabStopped()
+ mainActivityManager.getActiveTab().onTabStopped()
}
LaunchedEffect(mainActivityManager.selectedTabIndex) {
@@ -83,6 +83,9 @@ class MainActivity : BaseActivity() {
if (page != mainActivityManager.selectedTabIndex) {
mainActivityManager.selectTabAt(page)
}
+ mainActivityManager.tabLayoutState.animateScrollToItem(
+ mainActivityManager.selectedTabIndex
+ )
}
}
@@ -114,8 +117,8 @@ class MainActivity : BaseActivity() {
intent?.let {
if (it.hasExtra(HOME_SCREEN_SHORTCUT_EXTRA_KEY)) {
globalClass.mainActivityManager.jumpToFile(
- DocumentHolder.fromFile(File(it.getStringExtra(HOME_SCREEN_SHORTCUT_EXTRA_KEY)!!)),
- this
+ file = LocalFileHolder(File(it.getStringExtra(HOME_SCREEN_SHORTCUT_EXTRA_KEY)!!)),
+ context = this
)
intent = null
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt
index 5176cb0c..e19adedb 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/MainActivityManager.kt
@@ -1,8 +1,7 @@
package com.raival.compose.file.explorer.screen.main
import android.content.Context
-import androidx.compose.material3.DrawerState
-import androidx.compose.material3.DrawerValue
+import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
@@ -14,8 +13,8 @@ import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.extension.isNot
import com.raival.compose.file.explorer.screen.main.tab.Tab
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDeviceHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDevice
import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider
import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab
import kotlinx.coroutines.CoroutineScope
@@ -27,7 +26,7 @@ class MainActivityManager {
var title by mutableStateOf(globalClass.getString(R.string.main_activity_title))
var subtitle by mutableStateOf(emptyString)
- val storageDeviceHolders = arrayListOf()
+ val storageDevices = arrayListOf()
var showNewTabDialog by mutableStateOf(false)
var showAppInfoDialog by mutableStateOf(false)
@@ -35,13 +34,14 @@ class MainActivityManager {
var showSaveTextEditorFilesBeforeCloseDialog by mutableStateOf(false)
var isSavingTextEditorFiles by mutableStateOf(false)
+
var selectedTabIndex by mutableIntStateOf(0)
val tabs = mutableStateListOf()
- val drawerState = DrawerState(initialValue = DrawerValue.Closed)
+ val tabLayoutState = LazyListState()
fun setupTabs() {
- storageDeviceHolders.addAll(StorageProvider.getStorageDevices(globalClass))
+ storageDevices.addAll(StorageProvider.getStorageDevices(globalClass))
}
fun closeAllTabs() {
@@ -93,43 +93,47 @@ class MainActivityManager {
}
fun selectTabAt(index: Int) {
- if (tabs.isNotEmpty() && selectedTabIndex isNot index && selectedTabIndex < tabs.size) tabs[selectedTabIndex].onTabStopped()
+ if (tabs.isNotEmpty()
+ && selectedTabIndex isNot index
+ && selectedTabIndex < tabs.size
+ ) getActiveTab().onTabStopped()
selectedTabIndex = index
- tabs[selectedTabIndex].apply {
+ getActiveTab().apply {
if (!isCreated) onTabStarted() else onTabResumed()
}
}
fun replaceCurrentTabWith(tab: Tab) {
- if (tabs.isNotEmpty()) tabs[selectedTabIndex].onTabStopped()
+ if (tabs.isNotEmpty()) getActiveTab().onTabStopped()
tabs[selectedTabIndex] = tab
selectTabAt(selectedTabIndex)
}
- fun jumpToFile(file: DocumentHolder, context: Context) {
+ fun jumpToFile(file: LocalFileHolder, context: Context) {
openFile(file, context)
}
- private fun openFile(file: DocumentHolder, context: Context) {
+ private fun openFile(file: LocalFileHolder, context: Context) {
if (file.exists()) {
addTabAndSelect(FilesTab(file, context))
}
}
- fun canExit(coroutineScope: CoroutineScope): Boolean {
- if (drawerState.isOpen) {
- coroutineScope.launch {
- drawerState.close()
- }
- return false
- }
+ fun resumeActiveTab() {
+ getActiveTab().onTabResumed()
+ }
+
+ fun getActiveTab(): Tab {
+ return tabs[selectedTabIndex]
+ }
- if (tabs[selectedTabIndex].onBackPressed()) {
+ fun canExit(coroutineScope: CoroutineScope): Boolean {
+ if (getActiveTab().onBackPressed()) {
return false
}
- if (tabs[selectedTabIndex] !is HomeTab) {
+ if (getActiveTab() !is HomeTab && !globalClass.preferencesManager.behaviorPrefs.skipHomeWhenTabClosed) {
replaceCurrentTabWith(HomeTab())
return false
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt
index d515be94..b5c6c657 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/apps/ui/AppsTabContentView.kt
@@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -59,18 +58,15 @@ import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.common.ui.block
import com.raival.compose.file.explorer.screen.main.tab.apps.AppsTab
import com.raival.compose.file.explorer.screen.main.tab.apps.holder.AppHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask
import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRow
import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRowIcon
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import java.io.File
@Composable
-fun ColumnScope.AppsTabContentView(tab: AppsTab) {
+fun AppsTabContentView(tab: AppsTab) {
LaunchedEffect(tab.id) {
if (tab.appsList.isEmpty()) {
tab.fetchInstalledApps()
@@ -160,14 +156,11 @@ fun ColumnScope.AppsTabContentView(tab: AppsTab) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(
onClick = {
- globalClass.filesTabManager.filesTabTasks.add(
- CopyTask(arrayListOf(DocumentHolder.fromFile(File(selectedApp.path))))
- )
tab.previewAppDialog = null
globalClass.showMsg(R.string.new_task_has_been_added)
}
) {
- Text(text = stringResource(R.string.copy))
+ Text(text = stringResource(R.string.save))
}
}
}
@@ -351,7 +344,7 @@ fun ColumnScope.AppsTabContentView(tab: AppsTab) {
}
}
- androidx.compose.animation.AnimatedVisibility(
+ AnimatedVisibility(
modifier = Modifier.align(Alignment.Center),
visible = tab.isLoading || tab.isSearching
) {
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt
index a6dded66..4c1607c5 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTab.kt
@@ -5,13 +5,11 @@ import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.content.pm.ShortcutManager
-import android.content.res.AssetManager
import android.net.Uri
import android.os.Environment
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -19,81 +17,57 @@ import androidx.core.app.ShareCompat
import androidx.core.content.FileProvider.getUriForFile
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.addIfAbsent
import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.extension.getIndexIf
-import com.raival.compose.file.explorer.common.extension.isNot
+import com.raival.compose.file.explorer.common.extension.getMimeType
import com.raival.compose.file.explorer.common.extension.orIf
import com.raival.compose.file.explorer.common.extension.removeIf
import com.raival.compose.file.explorer.screen.main.MainActivity
import com.raival.compose.file.explorer.screen.main.tab.Tab
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.Action
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder
import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_DATE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_SIZE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.UpdateAction
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortFoldersFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortLargerFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortName
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNameRev
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNewerFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortOlderFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortSmallerFirst
import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getArchiveFiles
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getAudioFiles
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getDocumentFiles
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getImageFiles
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getRecentFiles
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getVideoFiles
import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask
-import com.raival.compose.file.explorer.screen.main.tab.files.task.DeleteTask
-import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTask
-import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTaskCallback
-import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTaskDetails
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.io.File
class FilesTab(
- val source: DocumentHolder,
+ val source: ContentHolder,
context: Context? = null
) : Tab() {
override val id = globalClass.generateUid()
companion object {
- fun isValidPath(path: String) = DocumentHolder.fromFullPath(path) isNot null
+ fun isValidLocalPath(path: String) = File(path).exists()
}
- val search = Search
- val taskDialog = TaskDialog
- val apkDialog = ApkDialog
- val compressDialog = CompressDialog
- val renameDialog = RenameDialog
- val fileOptionsDialog = FileOptionsDialog
- val openWithDialog = OpenWithDialog
+ val search = Search()
+ val apkDialog = ApkDialog()
+ val fileOptionsDialog = FileOptionsDialog()
+ val openWithDialog = OpenWithDialog()
+ val renameDialog = RenameDialog()
+ val newZipFileDialog = NewZipFileDialog()
- val homeDir: DocumentHolder =
- if (isSpecialDirectory(source) || source.isFolder) source else source.parent
- ?: StorageProvider.getPrimaryInternalStorage(globalClass).documentHolder
- var activeFolder: DocumentHolder = homeDir
+ val homeDir: ContentHolder =
+ if (source is VirtualFileHolder || source.isFolder) source else source.getParent()
+ ?: StorageProvider.getPrimaryInternalStorage(globalClass).contentHolder
+ var activeFolder: ContentHolder = homeDir
- val actions = arrayListOf()
-
- val activeFolderContent = mutableStateListOf()
+ val activeFolderContent = mutableStateListOf()
val contentListStates = hashMapOf()
var activeListState by mutableStateOf(LazyGridState())
- val currentPathSegments = mutableStateListOf()
+ val currentPathSegments = mutableStateListOf()
val currentPathSegmentsListState = LazyListState()
- val assetManager: AssetManager = globalClass.assets
-
- val selectedFiles = linkedMapOf()
+ val selectedFiles = linkedMapOf()
var lastSelectedFileIndex = -1
var highlightedFiles = arrayListOf()
@@ -107,7 +81,8 @@ class FilesTab(
var showBookmarkDialog by mutableStateOf(false)
var showMoreOptionsButton by mutableStateOf(false)
var showEmptyRecycleBin by mutableStateOf(false)
- var handleBackGesture by mutableStateOf(activeFolder.canAccessParent || selectedFiles.isNotEmpty())
+ var canCreateNewContent by mutableStateOf(true)
+ var handleBackGesture by mutableStateOf(activeFolder.hasParent() || selectedFiles.isNotEmpty())
var tabViewLabel by mutableStateOf(emptyString)
var isLoading by mutableStateOf(false)
@@ -117,16 +92,17 @@ class FilesTab(
override fun onTabStarted() {
super.onTabStarted()
- if (source.isFile) {
- source.parent?.let { parent ->
- if (parent.exists()) {
- openFolder(parent) {
- CoroutineScope(Dispatchers.Main).launch {
- getFileListState().scrollToItem(
- activeFolderContent.getIndexIf { path == source.path },
+ if (source.isFile()) {
+ source.getParent()?.let { parent ->
+ openFolder(parent) {
+ CoroutineScope(Dispatchers.Main).launch {
+ getFileListState().scrollToItem(
+ maxOf(
+ activeFolderContent.getIndexIf { uniquePath == source.uniquePath },
0
- )
- }
+ ),
+ 0
+ )
}
}
} ?: also {
@@ -135,7 +111,7 @@ class FilesTab(
highlightedFiles.apply {
clear()
- add(source.path)
+ add(source.uniquePath)
}
} else {
openFolder(homeDir)
@@ -144,22 +120,7 @@ class FilesTab(
override fun onTabResumed() {
requestHomeToolbarUpdate()
-
- actions.forEach { action ->
- if (action is UpdateAction) {
- reloadFiles()
- if (action.autoDismiss) {
- action.due = false
- action.isDone = true
- }
- }
-
- actions.removeIf { it.isDone }
- }
- }
-
- override fun onTabStopped() {
- super.onTabStopped()
+ detectFileChanges()
}
override val title: String
@@ -172,7 +133,7 @@ class FilesTab(
get() = tabViewLabel
init {
- if (source.isFile) {
+ if (source.isFile()) {
context?.let { openFile(context, source) }
}
}
@@ -212,19 +173,15 @@ class FilesTab(
private fun createTitle() = globalClass.getString(R.string.files_tab_title)
- fun addNewAction(action: Action): Action {
- return action.apply { actions.add(this) }
- }
-
override fun onBackPressed(): Boolean {
if (unselectAnySelectedFiles()) {
return true
} else if (handleBackGesture) {
highlightedFiles.apply {
clear()
- add(activeFolder.path)
+ add(activeFolder.uniquePath)
}
- openFolder(activeFolder.parent!!)
+ openFolder(activeFolder.getParent()!!)
return true
}
@@ -244,16 +201,16 @@ class FilesTab(
return false
}
- fun openFile(context: Context, item: DocumentHolder) {
- if (item.isApk || item.isApks) {
- ApkDialog.show(item)
+ fun openFile(context: Context, item: ContentHolder) {
+ if (item is LocalFileHolder && item.isApk()) {
+ apkDialog.show(item)
} else {
- item.openFile(context, anonymous = false, skipSupportedExtensions = false)
+ item.open(context, anonymous = false, skipSupportedExtensions = false, null)
}
}
fun openFolder(
- item: DocumentHolder,
+ item: ContentHolder,
rememberListState: Boolean = true,
rememberSelectedFiles: Boolean = false,
postEvent: () -> Unit = {}
@@ -264,7 +221,7 @@ class FilesTab(
selectedFiles.clear()
lastSelectedFileIndex = -1
} else {
- selectedFiles.removeIf { key, value -> !value.exists() }
+ selectedFiles.removeIf { key, value -> !value.isValid() }
if (selectedFiles.isEmpty()) lastSelectedFileIndex = -1
}
@@ -272,106 +229,99 @@ class FilesTab(
activeFolder = item
- showEmptyRecycleBin = activeFolder.hasParent(globalClass.recycleBinDir)
- || activeFolder.path == globalClass.recycleBinDir.path
+ listFiles { newContent ->
+ if (activeFolder is LocalFileHolder) {
+ showEmptyRecycleBin =
+ (activeFolder as LocalFileHolder).hasParent(globalClass.recycleBinDir)
+ || activeFolder.uniquePath == globalClass.recycleBinDir.uniquePath
+ } else {
+ showEmptyRecycleBin = false
+ }
- handleBackGesture = activeFolder.canAccessParent || selectedFiles.isNotEmpty()
+ canCreateNewContent = activeFolder.canAddNewContent
- updatePathList()
- listFiles { newContent ->
+ handleBackGesture = activeFolder.hasParent() || selectedFiles.isNotEmpty()
+
+ updatePathList()
+
requestHomeToolbarUpdate()
activeFolderContent.clear()
activeFolderContent.addAll(newContent)
if (!rememberListState) {
- contentListStates[item.path] = LazyGridState(0, 0)
+ contentListStates[item.uniquePath] = LazyGridState(0, 0)
}
- activeListState = contentListStates[item.path] ?: LazyGridState()
- .also { contentListStates[item.path] = it }
+ activeListState = contentListStates[item.uniquePath] ?: LazyGridState()
+ .also { contentListStates[item.uniquePath] = it }
postEvent()
}
}
+ fun detectFileChanges(): Boolean {
+ if (isLoading) return false
+ if (activeFolder is LocalFileHolder) {
+ val newContent =
+ (activeFolder as LocalFileHolder).file.listFiles()?.toCollection(arrayListOf())
+ ?.apply {
+ if (!globalClass.preferencesManager.fileListPrefs.showHiddenFiles) {
+ removeIf { it.name.startsWith(".") }
+ }
+ }
+ if (newContent != null && newContent.size != activeFolderContent.size) {
+ reloadFiles()
+ return true
+ }
+ if (activeFolderContent.any { (it as LocalFileHolder).hasSourceChanged() }) {
+ reloadFiles()
+ return true
+ }
+ } else if (activeFolder is ZipFileHolder) {
+ if (globalClass.zipManager.checkForSourceChanges()) {
+ reloadFiles()
+ return true
+ }
+ }
+ return false
+ }
+
fun quickReloadFiles() {
if (isLoading) return
- val temp = arrayListOf().apply { addAll(activeFolderContent) }
+ val temp = arrayListOf().apply { addAll(activeFolderContent) }
activeFolderContent.clear()
activeFolderContent.addAll(temp)
- handleBackGesture = activeFolder.canAccessParent || selectedFiles.isNotEmpty()
+ handleBackGesture = activeFolder.hasParent() || selectedFiles.isNotEmpty()
requestHomeToolbarUpdate()
showMoreOptionsButton = selectedFiles.size > 0
- showEmptyRecycleBin = activeFolder.hasParent(globalClass.recycleBinDir)
- || activeFolder.path == globalClass.recycleBinDir.path
+ if (activeFolder is LocalFileHolder) {
+ showEmptyRecycleBin =
+ (activeFolder as LocalFileHolder).hasParent(globalClass.recycleBinDir)
+ || activeFolder.uniquePath == globalClass.recycleBinDir.uniquePath
+ }
}
fun reloadFiles(postEvent: () -> Unit = {}) {
openFolder(activeFolder) { postEvent() }
}
- private fun listFiles(onReady: (ArrayList) -> Unit) {
+ private fun listFiles(onReady: (ArrayList) -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
isLoading = true
- foldersCount = 0
- filesCount = 0
-
- val result = if (isSpecialDirectory()) {
- when (activeFolder) {
- StorageProvider.audios -> getAudioFiles()
- StorageProvider.videos -> getVideoFiles()
- StorageProvider.images -> getImageFiles()
- StorageProvider.archives -> getArchiveFiles()
- StorageProvider.documents -> getDocumentFiles()
- StorageProvider.bookmarks -> getBookmarks()
- else -> getRecentFiles()
- }.apply {
- if (activeFolder != StorageProvider.recentFiles) {
- val sortingPrefs =
- globalClass.preferencesManager.filesSortingPrefs.getSortingPrefsFor(
- activeFolder
- )
- when (sortingPrefs.sortMethod) {
- SORT_BY_NAME -> {
- sortWith(if (sortingPrefs.reverseSorting) sortNameRev else sortName)
- }
-
- SORT_BY_DATE -> {
- sortWith(if (sortingPrefs.reverseSorting) sortOlderFirst else sortNewerFirst)
- }
-
- SORT_BY_SIZE -> {
- sortWith(if (sortingPrefs.reverseSorting) sortLargerFirst else sortSmallerFirst)
- }
- }
+ val result = activeFolder.listSortedContent()
- if (sortingPrefs.showFoldersFirst) sortWith(sortFoldersFirst)
- }
- }.also {
- filesCount = it.size
- }
- } else {
- activeFolder.listContent(
- sortingPrefs = globalClass.preferencesManager.filesSortingPrefs.getSortingPrefsFor(
- activeFolder
- )
- ) {
- if (it.isFile) filesCount++
- else foldersCount++
- }.apply {
- if (!globalClass.preferencesManager.displayPrefs.showHiddenFiles) {
- removeIf { it.isHidden }
- }
- }
+ activeFolder.getContentCount().let { contentCount ->
+ foldersCount = contentCount.folders
+ filesCount = contentCount.files
}
withContext(Dispatchers.Main) {
@@ -387,60 +337,47 @@ class FilesTab(
if (quickReload) quickReloadFiles()
}
- fun onNewFileCreated(fileName: String, openFolder: Boolean = false) {
- val newFile = activeFolder.findFile(fileName)
-
- newFile?.let {
+ fun onNewFileCreated(newFile: ContentHolder, openFolder: Boolean = false) {
+ if (openFolder) {
+ openFolder(newFile)
+ } else {
highlightedFiles.apply {
clear()
- add(it.path)
+ add(newFile.uniquePath)
}
reloadFiles {
CoroutineScope(Dispatchers.Main).launch {
val newItemIndex =
- activeFolderContent.getIndexIf { path == newFile.path }
+ activeFolderContent.getIndexIf { displayName == newFile.displayName }
if (newItemIndex > -1) {
getFileListState().scrollToItem(newItemIndex, 0)
}
-
- if (openFolder) {
- openFolder(newFile)
- }
}
}
}
}
- private fun getBookmarks() = globalClass.filesTabManager.bookmarks
- .map { DocumentHolder.fromFullPath(it) }
- .takeWhile { it != null } as ArrayList
-
- fun getFileListState() = contentListStates[activeFolder.path] ?: LazyGridState().also {
- contentListStates[activeFolder.path] = it
+ fun getFileListState() = contentListStates[activeFolder.uniquePath] ?: LazyGridState().also {
+ contentListStates[activeFolder.uniquePath] = it
}
private fun updateTabViewLabel() {
val fullName =
- activeFolder.getName().orIf(globalClass.getString(R.string.internal_storage)) {
- activeFolder.path == Environment.getExternalStorageDirectory().absolutePath
+ activeFolder.displayName.orIf(globalClass.getString(R.string.internal_storage)) {
+ activeFolder.uniquePath == Environment.getExternalStorageDirectory().absolutePath
}
tabViewLabel = if (fullName.length > 18) fullName.substring(0, 15) + "..." else fullName
}
private fun updatePathList() {
+ val path = generateSequence(activeFolder) { it.getParent() }
+
+ val newPathSegments = path.filter { it.canRead }.toList().reversed()
+
currentPathSegments.apply {
clear()
- add(activeFolder)
- if (activeFolder.canAccessParent) {
- var parentDir = activeFolder.parent!!
- add(parentDir)
- while (parentDir.canAccessParent) {
- parentDir = parentDir.parent!!
- add(parentDir)
- }
- }
- reverse()
+ addAll(newPathSegments)
}.also { updateTabViewLabel() }
}
@@ -448,47 +385,28 @@ class FilesTab(
globalClass.mainActivityManager.addTabAndSelect(tab)
}
- fun addNewTask(task: FilesTabTask) {
- globalClass.filesTabManager.filesTabTasks.add(task)
- globalClass.showMsg(R.string.new_task_has_been_added)
- }
-
fun hideDocumentOptionsMenu() {
- FileOptionsDialog.hide()
+ fileOptionsDialog.hide()
}
- fun deleteFiles(
- targetFiles: List,
- taskCallback: FilesTabTaskCallback,
- moveToRecycleBin: Boolean = true
- ) {
- CoroutineScope(Dispatchers.IO).launch {
- DeleteTask(targetFiles, moveToRecycleBin).execute(activeFolder, taskCallback)
- }
- }
-
- fun share(
- context: Context,
- targetDocumentHolder: DocumentHolder
- ) {
+ /**
+ * Shares the selected files.
+ * Only local content can be shared, other types must create a local copy first.
+ */
+ fun shareSelectedFiles(context: Context) {
val uris = arrayListOf()
- selectedFiles.forEach {
- val file = it.component2().toFile()
- if (file != null) {
+
+ selectedFiles.forEach { selectedFile ->
+ val content = selectedFile.component2()
+ if (content is LocalFileHolder) {
uris.add(
- getUriForFile(
- context,
- globalClass.packageName + ".provider",
- file
- )
+ getUriForFile(context, globalClass.packageName + ".provider", content.file)
)
- } else {
- uris.add(it.component2().uri)
}
}
val builder = ShareCompat.IntentBuilder(globalClass)
- .setType(if (uris.size == 1) targetDocumentHolder.mimeType else anyFileType)
+ .setType(if (uris.size == 1) uris[0].getMimeType(globalClass) else anyFileType)
uris.forEach {
builder.addStream(it)
}
@@ -505,24 +423,24 @@ class FilesTab(
)
}
- fun addToHomeScreen(context: Context, file: DocumentHolder) {
+ fun addToHomeScreen(context: Context, file: LocalFileHolder) {
val shortcutManager = context.getSystemService(ShortcutManager::class.java)
val pinShortcutInfo = ShortcutInfo
- .Builder(context, file.path)
+ .Builder(context, file.uniquePath)
.setIntent(
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
- putExtra("filePath", file.path)
+ putExtra("filePath", file.uniquePath)
flags = Intent.FLAG_ACTIVITY_BROUGHT_TO_FRONT
}
)
.setIcon(
android.graphics.drawable.Icon.createWithResource(
context,
- if (file.isFile) R.mipmap.default_shortcut_icon else R.mipmap.folder_shortcut_icon
+ if (file.isFile()) R.mipmap.default_shortcut_icon else R.mipmap.folder_shortcut_icon
)
)
- .setShortLabel(file.getName())
+ .setShortLabel(file.displayName)
.build()
val pinnedShortcutCallbackIntent =
shortcutManager.createShortcutResultIntent(pinShortcutInfo)
@@ -537,170 +455,88 @@ class FilesTab(
)
}
- fun isSpecialDirectory(source: DocumentHolder = activeFolder) = when (source) {
- StorageProvider.archives,
- StorageProvider.recentFiles,
- StorageProvider.images,
- StorageProvider.videos,
- StorageProvider.audios,
- StorageProvider.bookmarks,
- StorageProvider.documents -> true
-
- else -> false
- }
-
- fun canCreateNewFile() = !isSpecialDirectory()
-
- fun canRunTasks() = !isSpecialDirectory()
-
- object TaskDialog {
- var showTaskDialog by mutableStateOf(false)
- var taskDialogTitle by mutableStateOf(emptyString)
- var taskDialogSubtitle by mutableStateOf(emptyString)
- var taskDialogInfo by mutableStateOf(emptyString)
- var showTaskDialogProgressbar by mutableStateOf(true)
- var taskDialogProgress by mutableFloatStateOf(-1f)
- }
-
- object ApkDialog {
+ class ApkDialog {
var showApkDialog by mutableStateOf(false)
private set
- var apkFile: DocumentHolder? = null
+ var apkFile: LocalFileHolder? = null
private set
- var ApksArchive = false
- private set
- fun show(file: DocumentHolder) {
+ fun show(file: LocalFileHolder) {
apkFile = file
showApkDialog = true
- ApksArchive = file.isApks
}
fun hide() {
showApkDialog = false
- apkFile = null
}
}
- object CompressDialog {
- var showCompressDialog by mutableStateOf(false)
+ class FileOptionsDialog {
+ var showFileOptionsDialog by mutableStateOf(false)
private set
- var task: CompressTask? = null
+ var targetFile: ContentHolder? = null
private set
- fun show(task: CompressTask) {
- CompressDialog.task = task
- showCompressDialog = true
+ fun show(file: ContentHolder) {
+ targetFile = file
+ showFileOptionsDialog = true
}
fun hide() {
- showCompressDialog = false
+ showFileOptionsDialog = false
}
}
- object RenameDialog {
- var showRenameFileDialog by mutableStateOf(false)
+ class OpenWithDialog {
+ var showOpenWithDialog by mutableStateOf(false)
private set
- var targetFile: DocumentHolder? = null
+ var targetFile: LocalFileHolder? = null
private set
- fun show(file: DocumentHolder) {
+ fun show(file: LocalFileHolder) {
targetFile = file
- showRenameFileDialog = true
+ showOpenWithDialog = true
}
fun hide() {
- showRenameFileDialog = false
- targetFile = null
+ showOpenWithDialog = false
}
}
- object FileOptionsDialog {
- var showFileOptionsDialog by mutableStateOf(false)
+ class NewZipFileDialog {
+ var show by mutableStateOf(false)
private set
- var targetFile: DocumentHolder? = null
+ var task: CompressTask? = null
private set
- fun show(file: DocumentHolder) {
- targetFile = file
- showFileOptionsDialog = true
+ fun show(task: CompressTask) {
+ this.task = task
+ show = true
}
fun hide() {
- showFileOptionsDialog = false
- targetFile = null
+ show = false
}
}
- object OpenWithDialog {
- var showOpenWithDialog by mutableStateOf(false)
+ class RenameDialog {
+ var show by mutableStateOf(false)
private set
- var targetFile: DocumentHolder? = null
+ var targetContent: ContentHolder? = null
private set
- fun show(file: DocumentHolder) {
- targetFile = file
- showOpenWithDialog = true
+ fun show(content: ContentHolder) {
+ targetContent = content
+ show = true
}
fun hide() {
- showOpenWithDialog = false
- targetFile = null
+ show = false
}
}
- object Search {
+ class Search {
var searchQuery by mutableStateOf(emptyString)
- var searchResults = mutableStateListOf()
- }
-
- val taskCallback = object : FilesTabTaskCallback(CoroutineScope(Dispatchers.IO)) {
- override fun onPrepare(details: FilesTabTaskDetails) {
- taskDialog.apply {
- showTaskDialog = true
- taskDialogTitle = details.title
- taskDialogSubtitle = details.subtitle
- showTaskDialogProgressbar = true
- taskDialogProgress = details.progress
- taskDialogInfo = details.info
- }
- }
-
- override fun onReport(details: FilesTabTaskDetails) {
- taskDialog.apply {
- taskDialogTitle = details.title
- taskDialogSubtitle = details.subtitle
- taskDialogProgress = details.progress
- taskDialogInfo = details.info
- }
- }
-
- override fun onComplete(details: FilesTabTaskDetails) {
- highlightedFiles.clear()
-
- details.task.getSourceFiles().forEach {
- activeFolder.findFile(it.getName())?.let { file ->
- highlightedFiles.addIfAbsent(file.path)
- }
- }
-
- globalClass.showMsg(buildString {
- append(details.subtitle)
- })
-
- TaskDialog.showTaskDialog = false
- showTasksPanel = false
-
- reloadFiles()
-
- globalClass.filesTabManager.filesTabTasks.removeIf { it.id == details.task.id }
- }
-
- override fun onFailed(details: FilesTabTaskDetails) {
- globalClass.showMsg(details.subtitle)
- TaskDialog.showTaskDialog = false
- showTasksPanel = false
- reloadFiles()
- }
+ var searchResults = mutableStateListOf()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/manager/FilesTabManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTabManager.kt
similarity index 62%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/manager/FilesTabManager.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTabManager.kt
index f1bb8728..03872db3 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/manager/FilesTabManager.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/FilesTabManager.kt
@@ -1,15 +1,11 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.manager
+package com.raival.compose.file.explorer.screen.main.tab.files
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.setValue
import androidx.datastore.preferences.core.stringSetPreferencesKey
-import com.raival.compose.file.explorer.screen.main.tab.files.task.FilesTabTask
import com.raival.compose.file.explorer.screen.preferences.misc.prefMutableState
class FilesTabManager {
- val filesTabTasks = mutableStateListOf()
-
var bookmarks by prefMutableState(
keyName = "bookmarks",
defaultValue = emptySet(),
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt
index 8cf9a255..1020fbe9 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/DocumentFileMapper.kt
@@ -3,52 +3,54 @@ package com.raival.compose.file.explorer.screen.main.tab.files.coil
import coil3.map.Mapper
import coil3.request.Options
import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_AI
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_APK
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_ARCHIVE
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_AUDIO
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_CODE
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_CSS
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_DOC
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_FOLDER
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_FONT
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_ISO
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_JAVA
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_JS
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_KOTLIN
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_PPT
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_PSD
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_SQL
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_TEXT
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_VCF
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_VECTOR
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder.Companion.FILE_TYPE_XLS
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.aiFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.archiveFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.audioFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.codeFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.cssFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.docFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.editableFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.excelFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.fontFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.isoFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pptFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.psdFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.sqlFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vcfFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vectorFileType
-class DocumentFileMapper : Mapper {
- override fun map(data: DocumentHolder, options: Options): Any {
+class DocumentFileMapper : Mapper {
+ override fun map(data: ContentHolder, options: Options): Any {
return when {
- data.getFileIconType() == FILE_TYPE_FOLDER -> R.drawable.baseline_folder_24
- data.getFileIconType() == FILE_TYPE_AI -> R.drawable.ai_file_extension
- data.getFileIconType() == FILE_TYPE_CSS -> R.drawable.css_file_extension
- data.getFileIconType() == FILE_TYPE_ISO -> R.drawable.iso_file_extension
- data.getFileIconType() == FILE_TYPE_JS -> R.drawable.js_file_extension
- data.getFileIconType() == FILE_TYPE_PSD -> R.drawable.psd_file_extension
- data.getFileIconType() == FILE_TYPE_SQL -> R.drawable.sql_file_extension
- data.getFileIconType() == FILE_TYPE_VCF -> R.drawable.vcf_file_extension
- data.getFileIconType() == FILE_TYPE_JAVA -> R.drawable.css_file_extension
- data.getFileIconType() == FILE_TYPE_KOTLIN -> R.drawable.css_file_extension
- data.getFileIconType() == FILE_TYPE_DOC -> R.drawable.doc_file_extension
- data.getFileIconType() == FILE_TYPE_XLS -> R.drawable.xls_file_extension
- data.getFileIconType() == FILE_TYPE_PPT -> R.drawable.ppt_file_extension
- data.getFileIconType() == FILE_TYPE_FONT -> R.drawable.font_file_extension
- data.getFileIconType() == FILE_TYPE_VECTOR -> R.drawable.vector_file_extension
- data.getFileIconType() == FILE_TYPE_AUDIO -> R.drawable.music_file_extension
- data.getFileIconType() == FILE_TYPE_CODE -> R.drawable.css_file_extension
- data.getFileIconType() == FILE_TYPE_TEXT -> R.drawable.txt_file_extension
- data.getFileIconType() == FILE_TYPE_ARCHIVE -> R.drawable.zip_file_extension
- data.getFileIconResource() == FILE_TYPE_APK -> R.drawable.apk_file_extension
- canUseCoil(data) -> data.uri
+ data.isFolder -> R.drawable.baseline_folder_24
+ data is LocalFileHolder && canUseCoil(data) -> data.icon
+ data.extension == aiFileType -> R.drawable.ai_file_extension
+ data.extension == cssFileType -> R.drawable.css_file_extension
+ data.extension == isoFileType -> R.drawable.iso_file_extension
+ data.extension == jsFileType -> R.drawable.js_file_extension
+ data.extension == psdFileType -> R.drawable.psd_file_extension
+ data.extension == sqlFileType -> R.drawable.sql_file_extension
+ data.extension == vcfFileType -> R.drawable.vcf_file_extension
+ data.extension == javaFileType -> R.drawable.css_file_extension
+ data.extension == kotlinFileType -> R.drawable.css_file_extension
+ imageFileType.contains(data.extension) -> R.drawable.jpg_file_extension
+ docFileType.contains(data.extension) -> R.drawable.doc_file_extension
+ excelFileType.contains(data.extension) -> R.drawable.xls_file_extension
+ pptFileType.contains(data.extension) -> R.drawable.ppt_file_extension
+ fontFileType.contains(data.extension) -> R.drawable.font_file_extension
+ vectorFileType.contains(data.extension) -> R.drawable.vector_file_extension
+ audioFileType.contains(data.extension) -> R.drawable.music_file_extension
+ codeFileType.contains(data.extension) -> R.drawable.css_file_extension
+ editableFileType.contains(data.extension) -> R.drawable.txt_file_extension
+ archiveFileType.contains(data.extension) -> R.drawable.zip_file_extension
+ apkFileType == data.extension -> R.drawable.apk_file_extension
else -> R.drawable.unknown_file_extension
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt
index f95c02af..dca5370b 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/coil/Utils.kt
@@ -1,7 +1,16 @@
package com.raival.compose.file.explorer.screen.main.tab.files.coil
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pdfFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.videoFileType
-fun canUseCoil(documentHolder: DocumentHolder): Boolean {
- return (documentHolder.isFile && documentHolder.isImage || documentHolder.isVideo || documentHolder.isApk || documentHolder.isPdf)
+fun canUseCoil(contentHolder: ContentHolder): Boolean {
+ return (contentHolder.isFile()
+ && imageFileType.contains(contentHolder.extension)
+ || videoFileType.contains(contentHolder.extension)
+ || contentHolder.extension == apkFileType
+ || contentHolder.extension == pdfFileType
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt
index fe07629f..85256b18 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ContentHolder.kt
@@ -1,10 +1,199 @@
package com.raival.compose.file.explorer.screen.main.tab.files.holder
+import android.content.Context
+import androidx.annotation.DrawableRes
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.aiFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.archiveFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.audioFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.codeFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.cssFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.docFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.editableFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.excelFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.fontFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.isoFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pptFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.psdFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.sqlFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vcfFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vectorFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_DATE
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_SIZE
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortFoldersFirst
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortLargerFirst
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortName
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNameRev
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNewerFirst
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortOlderFirst
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortSmallerFirst
+/**
+ * Abstract class representing a content holder.
+ * This class provides common properties and methods for managing content.
+ */
abstract class ContentHolder {
val uid = globalClass.generateUid()
+ abstract val uniquePath: String
- abstract fun getName(): String
- abstract fun getContent(): Any
+ abstract val displayName: String
+ abstract val details: String
+ abstract val icon: Any
+
+ @get:DrawableRes
+ abstract val iconPlaceholder: Int
+
+ abstract val isFolder: Boolean
+ abstract val lastModified: Long
+ abstract val size: Long
+ abstract val extension: String
+ abstract val canRead: Boolean
+ abstract val canWrite: Boolean
+
+ abstract val canAddNewContent: Boolean
+
+ /**
+ * Returns unsorted content list, use listSortedContent() to get sorted list.
+ */
+ abstract suspend fun listContent(): ArrayList
+ abstract fun getParent(): ContentHolder?
+
+ open fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) {
+ onCreated(null)
+ }
+
+ open fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) {
+ onCreated(null)
+ }
+
+ abstract fun getContentCount(): ContentCount
+ abstract fun findFile(name: String): ContentHolder?
+
+ /**
+ * Checks if the content holder is a valid content.
+ * For example, it might check if the underlying file or folder still exists on the file system.
+ *
+ * @return `true` if the content holder is valid, `false` otherwise.
+ */
+ abstract fun isValid(): Boolean
+
+ open fun open(
+ context: Context,
+ anonymous: Boolean,
+ skipSupportedExtensions: Boolean,
+ customMimeType: String?
+ ) {
+ }
+
+ open fun isFile(): Boolean {
+ return !isFolder
+ }
+
+ open fun hasParent(onlyReadable: Boolean = true): Boolean {
+ val parent = getParent()
+ return parent != null && parent.isValid() && (!onlyReadable || parent.canRead)
+ }
+
+ open suspend fun walk(includeEmptyFolders: Boolean, onContent: (ContentHolder) -> Unit) {
+ if (!isValid() || !isFolder) return
+
+ if (listContent().isEmpty() && !includeEmptyFolders) return
+
+ listContent().forEach {
+ onContent(it)
+ it.walk(includeEmptyFolders, onContent)
+ }
+ }
+
+ open suspend fun listSortedContent(): ArrayList {
+ val sortingPrefs = globalClass.preferencesManager.filesSortingPrefs.getSortingPrefsFor(this)
+
+ return listContent().apply {
+ when (sortingPrefs.sortMethod) {
+ SORT_BY_NAME -> {
+ sortWith(if (sortingPrefs.reverseSorting) sortNameRev else sortName)
+ }
+
+ SORT_BY_DATE -> {
+ sortWith(if (sortingPrefs.reverseSorting) sortOlderFirst else sortNewerFirst)
+ }
+
+ SORT_BY_SIZE -> {
+ sortWith(if (sortingPrefs.reverseSorting) sortLargerFirst else sortSmallerFirst)
+ }
+ }
+
+ if (sortingPrefs.showFoldersFirst) sortWith(sortFoldersFirst)
+
+ if (!globalClass.preferencesManager.fileListPrefs.showHiddenFiles) {
+ removeIf { it.isHidden() }
+ }
+ }
+ }
+
+ fun isApk(): Boolean = extension.endsWith(apkFileType)
+
+ fun isHidden(): Boolean = displayName.startsWith(".")
+
+ fun getFormattedFileCount(filesCount: Int, foldersCount: Int): String {
+ return buildString {
+ if (foldersCount == 0 && filesCount == 0) {
+ append(globalClass.getString(R.string.empty_folder))
+ } else {
+ if (foldersCount > 0) {
+ append(
+ globalClass.getString(
+ R.string.folders_count,
+ foldersCount
+ )
+ )
+ if (filesCount > 0) append(", ")
+ }
+ if (filesCount > 0) {
+ append(
+ globalClass.getString(
+ R.string.files_count,
+ filesCount
+ )
+ )
+ }
+ }
+ }
+ }
+
+ @DrawableRes
+ fun getContentIconPlaceholderResource(): Int {
+ return when {
+ isFolder -> R.drawable.baseline_folder_24
+ extension == aiFileType -> R.drawable.ai_file_extension
+ extension == cssFileType -> R.drawable.css_file_extension
+ extension == isoFileType -> R.drawable.iso_file_extension
+ extension == jsFileType -> R.drawable.js_file_extension
+ extension == psdFileType -> R.drawable.psd_file_extension
+ extension == sqlFileType -> R.drawable.sql_file_extension
+ extension == vcfFileType -> R.drawable.vcf_file_extension
+ extension == javaFileType -> R.drawable.css_file_extension
+ extension == kotlinFileType -> R.drawable.css_file_extension
+ imageFileType.contains(extension) -> R.drawable.jpg_file_extension
+ docFileType.contains(extension) -> R.drawable.doc_file_extension
+ excelFileType.contains(extension) -> R.drawable.xls_file_extension
+ pptFileType.contains(extension) -> R.drawable.ppt_file_extension
+ fontFileType.contains(extension) -> R.drawable.font_file_extension
+ vectorFileType.contains(extension) -> R.drawable.vector_file_extension
+ audioFileType.contains(extension) -> R.drawable.music_file_extension
+ codeFileType.contains(extension) -> R.drawable.css_file_extension
+ editableFileType.contains(extension) -> R.drawable.txt_file_extension
+ archiveFileType.contains(extension) -> R.drawable.zip_file_extension
+ apkFileType == extension -> R.drawable.apk_file_extension
+ else -> R.drawable.unknown_file_extension
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/DocumentHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/DocumentHolder.kt
deleted file mode 100644
index 5d67650c..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/DocumentHolder.kt
+++ /dev/null
@@ -1,575 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.holder
-
-import android.content.ActivityNotFoundException
-import android.content.Context
-import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.webkit.MimeTypeMap
-import androidx.compose.runtime.Immutable
-import androidx.core.content.FileProvider
-import androidx.core.net.toFile
-import androidx.documentfile.provider.DocumentFile
-import com.anggrayudi.storage.file.DocumentFileCompat
-import com.anggrayudi.storage.file.deleteRecursively
-import com.anggrayudi.storage.file.extension
-import com.anggrayudi.storage.file.getAbsolutePath
-import com.anggrayudi.storage.file.getBasePath
-import com.anggrayudi.storage.file.getStorageId
-import com.anggrayudi.storage.file.hasParent
-import com.anggrayudi.storage.file.isEmpty
-import com.anggrayudi.storage.file.isRawFile
-import com.anggrayudi.storage.file.mimeType
-import com.anggrayudi.storage.file.openInputStream
-import com.anggrayudi.storage.file.openOutputStream
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.conditions
-import com.raival.compose.file.explorer.common.extension.drawableToBitmap
-import com.raival.compose.file.explorer.common.extension.emptyString
-import com.raival.compose.file.explorer.common.extension.isNot
-import com.raival.compose.file.explorer.common.extension.toFormattedDate
-import com.raival.compose.file.explorer.common.extension.toFormattedSize
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.aiFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkBundleFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.archiveFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.audioFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.codeFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.cssFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.docFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.editableFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.excelFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.fontFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.imageFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.isoFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pdfFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.pptFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.psdFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.sqlFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.svgFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vcfFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.vectorFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.videoFileType
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileSortingPrefs
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_DATE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_SIZE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortFoldersFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortLargerFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortName
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNameRev
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortNewerFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortOlderFirst
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.sortSmallerFirst
-import java.io.File
-
-@Immutable
-data class DocumentHolder(val documentFile: DocumentFile) : ContentHolder() {
- var formattedDetailsCache = emptyString
-
- override fun getName() = documentFile.name ?: UNKNOWN_NAME
- override fun getContent() = documentFile
-
- val fileExtension by lazy { documentFile.extension.lowercase() }
-
- val fileSize by lazy { documentFile.length() }
-
- val lastModified by lazy { documentFile.lastModified() }
-
- val isHidden by lazy { getName().startsWith(".") }
-
- val uri by lazy { documentFile.uri }
-
- val path by lazy { documentFile.getAbsolutePath(globalClass) }
-
- val isFile by lazy { documentFile.isFile }
-
- val isFolder by lazy { documentFile.isDirectory }
-
- val isArchive by lazy { isFile && archiveFileType.contains(fileExtension) }
-
- val isApk by lazy { isFile && fileExtension == apkFileType }
-
- val isApks by lazy { isFile && apkBundleFileType.contains(fileExtension) }
-
- val isImage by lazy { isFile && imageFileType.contains(fileExtension) }
-
- val isVideo by lazy { isFile && videoFileType.contains(fileExtension) }
-
- val isPdf by lazy { isFile && fileExtension == pdfFileType }
-
- val basePath by lazy { documentFile.getBasePath(globalClass) }
-
- val storageId by lazy { documentFile.getStorageId(globalClass) }
-
- val mimeType by lazy { documentFile.mimeType ?: getMimeType(uri) ?: anyFileType }
-
- val canAccessParent by lazy { documentFile.parentFile != null || parentAlt != null }
-
- val parent: DocumentHolder? by lazy {
- documentFile.parentFile?.let { DocumentHolder(it) } ?: parentAlt
- }
-
- private val parentAlt: DocumentHolder? by lazy {
- val file = File(path)
- if (file.exists() && file.canRead()) {
- file.parentFile?.takeIf { it.exists() && it.canRead() }?.let {
- DocumentHolder(DocumentFile.fromFile(it))
- }
- } else null
- }
-
- fun exists() = documentFile.exists()
-
- fun isEmpty() = documentFile.isEmpty(globalClass)
-
- fun hasParent(parent: DocumentHolder): Boolean =
- documentFile.hasParent(globalClass, parent.documentFile)
-
- private fun getMimeType(uri: Uri): String? = MimeTypeMap
- .getSingleton()
- .getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(uri.toString()))
-
- fun findFile(name: String): DocumentHolder? =
- documentFile.findFile(name)?.let { DocumentHolder(it) }
-
- fun delete() = documentFile.delete()
-
- fun deleteRecursively() = documentFile.deleteRecursively(globalClass)
-
- fun openInputStream() = documentFile.openInputStream(globalClass)
-
- fun openOutputStream() = documentFile.openOutputStream(globalClass)
-
- fun createSubFile(name: String) =
- documentFile.createFile(anyFileType, name)?.let { DocumentHolder(it) }
-
- fun createSubFolder(name: String) =
- documentFile.createDirectory(name)?.let { DocumentHolder(it) }
-
- fun renameTo(newName: String) {
- documentFile.renameTo(newName)
- }
-
- private fun createUri() = if (documentFile.isRawFile) {
- FileProvider.getUriForFile(
- globalClass,
- "com.raival.compose.file.explorer.provider",
- toFile()!!
- )
- } else {
- uri
- }
-
- fun walk(includeNoneEmptyFolders: Boolean = false): List {
- val fileTree = mutableListOf()
- listContent(false) {
- if (it.isFolder) {
- if (it.isEmpty()) {
- fileTree.add(it)
- } else {
- if (includeNoneEmptyFolders) fileTree.add(it)
- fileTree.addAll(it.walk(includeNoneEmptyFolders))
- }
- } else {
- fileTree.add(it)
- }
- }
- return fileTree
- }
-
- fun analyze(
- onCountFile: () -> Unit,
- onCountFolder: (isEmpty: Boolean) -> Unit,
- onCountSize: (size: Long) -> Unit
- ) {
- if (documentFile.isFile) {
- onCountFile()
- onCountSize(fileSize)
- } else if (documentFile.isDirectory) {
- onCountFolder(documentFile.isEmpty(globalClass))
- listContent(false) { file ->
- file.analyze(onCountFile, onCountFolder, onCountSize)
- }
- }
- }
-
- fun listContent(
- sort: Boolean = true,
- sortingPrefs: FileSortingPrefs = FileSortingPrefs(),
- onFile: (DocumentHolder) -> Unit = { }
- ): ArrayList {
- return arrayListOf().apply {
- documentFile.listFiles().forEach {
- DocumentHolder(it).let { file ->
- add(file)
- onFile(file)
- }
- }
-
- if (sort) {
- when (sortingPrefs.sortMethod) {
- SORT_BY_NAME -> {
- sortWith(if (sortingPrefs.reverseSorting) sortNameRev else sortName)
- }
-
- SORT_BY_DATE -> {
- sortWith(if (sortingPrefs.reverseSorting) sortOlderFirst else sortNewerFirst)
- }
-
- SORT_BY_SIZE -> {
- sortWith(if (sortingPrefs.reverseSorting) sortLargerFirst else sortSmallerFirst)
- }
- }
-
- if (sortingPrefs.showFoldersFirst) sortWith(sortFoldersFirst)
- }
- }
- }
-
- fun getFormattedDetails(
- useCache: Boolean = false,
- showFolderContentCount: Boolean
- ): String {
- if (useCache && formattedDetailsCache.isNotEmpty()) {
- return formattedDetailsCache
- }
-
- val separator = " | "
-
- formattedDetailsCache = buildString {
- append(documentFile.lastModified().toFormattedDate())
- if (showFolderContentCount) append(separator)
- if (documentFile.isFile) {
- if (!showFolderContentCount) append(separator)
- append(documentFile.length().toFormattedSize())
- append(separator)
- append(fileExtension)
- } else if (showFolderContentCount) {
- append(getFormattedFileCount())
- }
- }
-
- return formattedDetailsCache
- }
-
- fun toFile(): File? {
- if (documentFile.isRawFile) {
- return uri.toFile()
- }
- return null
- }
-
- fun writeText(text: String) {
- if (documentFile.isRawFile) {
- File(path).writeText(text)
- } else {
- documentFile.openOutputStream(globalClass, false)?.use {
- it.write(text.toByteArray())
- }
- }
- }
-
- fun readText() = documentFile.openInputStream(globalClass)?.bufferedReader()?.use { reader ->
- val text = reader.readText()
- reader.close()
- text
- } ?: emptyString
-
- fun readLines(
- maxLines: Int = -1,
- onReadLine: (index: Int, line: String) -> Unit
- ) = documentFile.openInputStream(globalClass)?.bufferedReader()?.use { reader ->
- var lineCount = 0
- reader.forEachLine { line ->
- if (maxLines in 1..lineCount) return@forEachLine
- onReadLine(lineCount, line)
- lineCount++
- }
- } ?: emptyString
-
- fun appendText(text: String) {
- if (documentFile.isRawFile) {
- File(path).appendText(text)
- } else {
- documentFile.openOutputStream(globalClass, true)?.use {
- it.write(text.toByteArray())
- }
- }
- }
-
- private fun getFormattedFileCount(): String {
- var filesCount = 0
- var foldersCount = 0
-
- listContent(false) {
- if (it.isFile) filesCount++ else foldersCount++
- }
-
- return getFormattedFileCount(filesCount, foldersCount)
- }
-
- fun getFormattedFileCount(filesCount: Int, foldersCount: Int): String {
- return buildString {
- if (foldersCount == 0 && filesCount == 0) {
- append(globalClass.getString(R.string.empty_folder))
- } else {
- if (foldersCount > 0) {
- append(
- globalClass.getString(
- R.string.folders_count,
- foldersCount
- )
- )
- if (filesCount > 0) append(", ")
- }
- if (filesCount > 0) {
- append(
- globalClass.getString(
- R.string.files_count,
- filesCount
- )
- )
- }
- }
- }
- }
-
- fun openFile(
- context: Context,
- anonymous: Boolean,
- skipSupportedExtensions: Boolean,
- customMimeType: String? = null
- ) {
- if (!skipSupportedExtensions && handleSupportedFiles(context)) {
- return
- }
-
- Intent(Intent.ACTION_VIEW).let { newIntent ->
- newIntent.setDataAndType(
- createUri(),
- customMimeType ?: if (anonymous) anyFileType else documentFile.mimeType
- )
-
- newIntent.addFlags(
- Intent.FLAG_ACTIVITY_NEW_TASK
- or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
- or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
- or Intent.FLAG_GRANT_READ_URI_PERMISSION
- or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
- )
-
- try {
- context.startActivity(newIntent)
- } catch (e: ActivityNotFoundException) {
- if (!anonymous) {
- openFile(context, anonymous = true, skipSupportedExtensions = true)
- } else {
- globalClass.showMsg(R.string.no_app_can_open_file)
- }
- } catch (e: Exception) {
- with(globalClass) {
- showMsg(getString(R.string.failed_to_open_this_file))
- log(e)
- }
- }
- }
- }
-
- private fun handleSupportedFiles(context: Context): Boolean {
- val ext = fileExtension
-
- if (FileMimeType.conditions { codeFileType.contains(ext) || editableFileType.contains(ext) }) {
- globalClass.textEditorManager.openTextEditor(
- this,
- context
- )
- return true
- }
-
- return false
- }
-
- fun getAppsHandlingFile(mimeType: String = emptyString): List {
- val packageManager: PackageManager = globalClass.packageManager
-
- val intent = Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(createUri(), mimeType.ifEmpty { this@DocumentHolder.mimeType })
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
- }
-
- val appsList = ArrayList()
-
- packageManager.queryIntentActivities(
- intent,
- PackageManager.MATCH_ALL
- ).onEach {
- globalClass.grantUriPermission(
- it.activityInfo.packageName,
- createUri(),
- Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
- )
- appsList.add(
- OpenWithActivityHolder(
- label = it.activityInfo.loadLabel(packageManager).toString(),
- name = it.activityInfo.name,
- packageName = it.activityInfo.packageName,
- icon = it.activityInfo.loadIcon(packageManager).drawableToBitmap(),
- )
- )
- }
-
- return appsList
- }
-
- fun openFileWithPackage(context: Context, packageName: String, className: String) {
- val uri = createUri()
-
- val intent = Intent(Intent.ACTION_VIEW).apply {
- setDataAndType(uri, mimeType)
- addFlags(
- Intent.FLAG_ACTIVITY_NEW_TASK
- or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
- or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
- or Intent.FLAG_GRANT_READ_URI_PERMISSION
- or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
- )
- setPackage(packageName)
- setClassName(packageName, className)
- }
-
- if (intent.resolveActivity(globalClass.packageManager) != null) {
- context.startActivity(intent)
- } else {
- globalClass.showMsg("No app found to open this file.")
- }
- }
-
- fun getFileIconType(): Int {
- if (isFolder) {
- return FILE_TYPE_FOLDER
- } else if (fileExtension == aiFileType) {
- return FILE_TYPE_AI
- } else if (fileExtension == apkFileType) {
- return FILE_TYPE_APK
- } else if (fileExtension == cssFileType) {
- return FILE_TYPE_CSS
- } else if (fileExtension == isoFileType) {
- return FILE_TYPE_ISO
- } else if (fileExtension == jsFileType) {
- return FILE_TYPE_JS
- } else if (fileExtension == psdFileType) {
- return FILE_TYPE_PSD
- } else if (fileExtension == sqlFileType) {
- return FILE_TYPE_SQL
- } else if (fileExtension == svgFileType) {
- return FILE_TYPE_SVG
- } else if (fileExtension == vcfFileType) {
- return FILE_TYPE_VCF
- } else if (fileExtension == pdfFileType) {
- return FILE_TYPE_PDF
- } else if (docFileType.contains(fileExtension)) {
- return FILE_TYPE_DOC
- } else if (excelFileType.contains(fileExtension)) {
- return FILE_TYPE_XLS
- } else if (pptFileType.contains(fileExtension)) {
- return FILE_TYPE_PPT
- } else if (fontFileType.contains(fileExtension)) {
- return FILE_TYPE_FONT
- } else if (vectorFileType.contains(fileExtension)) {
- return FILE_TYPE_VECTOR
- } else if (archiveFileType.contains(fileExtension) || apkBundleFileType.contains(fileExtension)) {
- return FILE_TYPE_ARCHIVE
- } else if (videoFileType.contains(fileExtension)) {
- return FILE_TYPE_VIDEO
- } else if (codeFileType.contains(fileExtension)) {
- return FILE_TYPE_CODE
- } else if (editableFileType.contains(fileExtension)) {
- return FILE_TYPE_TEXT
- } else if (imageFileType.contains(fileExtension)) {
- return FILE_TYPE_IMAGE
- } else if (audioFileType.contains(fileExtension)) {
- return FILE_TYPE_AUDIO
- } else {
- return FILE_TYPE_UNKNOWN
- }
- }
-
- fun getFileIconResource() = when (getFileIconType()) {
- FILE_TYPE_FOLDER -> R.drawable.baseline_folder_24
- FILE_TYPE_AI -> R.drawable.ai_file_extension
- FILE_TYPE_APK -> R.drawable.apk_file_extension
- FILE_TYPE_CSS -> R.drawable.css_file_extension
- FILE_TYPE_ISO -> R.drawable.iso_file_extension
- FILE_TYPE_JS -> R.drawable.js_file_extension
- FILE_TYPE_PDF -> R.drawable.pdf_file_extension
- FILE_TYPE_PSD -> R.drawable.psd_file_extension
- FILE_TYPE_SQL -> R.drawable.sql_file_extension
- FILE_TYPE_SVG -> R.drawable.svg_file_extension
- FILE_TYPE_VCF -> R.drawable.vcf_file_extension
- FILE_TYPE_JAVA -> R.drawable.javascript_file_extension
- FILE_TYPE_KOTLIN -> R.drawable.css_file_extension
- FILE_TYPE_DOC -> R.drawable.doc_file_extension
- FILE_TYPE_XLS -> R.drawable.xls_file_extension
- FILE_TYPE_PPT -> R.drawable.ppt_file_extension
- FILE_TYPE_FONT -> R.drawable.font_file_extension
- FILE_TYPE_VECTOR -> R.drawable.vector_file_extension
- FILE_TYPE_VIDEO -> R.drawable.video_file_extension
- FILE_TYPE_AUDIO -> R.drawable.music_file_extension
- FILE_TYPE_IMAGE -> R.drawable.jpg_file_extension
- FILE_TYPE_CODE -> R.drawable.css_file_extension
- FILE_TYPE_TEXT -> R.drawable.txt_file_extension
- FILE_TYPE_ARCHIVE -> R.drawable.zip_file_extension
- else -> R.drawable.unknown_file_extension
- }
-
- companion object {
- const val UNKNOWN_NAME = "UNKNOWN"
-
- const val FILE_TYPE_FOLDER = -1
- const val FILE_TYPE_UNKNOWN = 0
- const val FILE_TYPE_AI = 1
- const val FILE_TYPE_APK = 2
- const val FILE_TYPE_CSS = 3
- const val FILE_TYPE_ISO = 4
- const val FILE_TYPE_JS = 5
- const val FILE_TYPE_PDF = 6
- const val FILE_TYPE_PSD = 7
- const val FILE_TYPE_SQL = 8
- const val FILE_TYPE_SVG = 9
- const val FILE_TYPE_VCF = 10
- const val FILE_TYPE_JAVA = 11
- const val FILE_TYPE_KOTLIN = 12
- const val FILE_TYPE_DOC = 13
- const val FILE_TYPE_XLS = 14
- const val FILE_TYPE_PPT = 15
- const val FILE_TYPE_FONT = 16
- const val FILE_TYPE_VECTOR = 17
- const val FILE_TYPE_VIDEO = 18
- const val FILE_TYPE_AUDIO = 19
- const val FILE_TYPE_IMAGE = 20
- const val FILE_TYPE_CODE = 21
- const val FILE_TYPE_TEXT = 22
- const val FILE_TYPE_ARCHIVE = 23
-
-
- fun fromFullPath(path: String): DocumentHolder? {
- val doc = DocumentFileCompat.fromFullPath(globalClass, path)
- return if (doc isNot null) DocumentHolder(doc!!) else null
- }
-
- fun fromFile(file: File): DocumentHolder {
- return DocumentHolder(DocumentFile.fromFile(file))
- }
-
- fun fromUri(uri: Uri): DocumentHolder? {
- val doc = DocumentFileCompat.fromUri(globalClass, uri)
- return if (doc isNot null) DocumentHolder(doc!!) else null
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt
new file mode 100644
index 00000000..9f543d51
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/LocalFileHolder.kt
@@ -0,0 +1,278 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.holder
+
+import android.content.ActivityNotFoundException
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import androidx.core.content.FileProvider
+import com.anggrayudi.storage.file.getBasePath
+import com.anggrayudi.storage.file.mimeType
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.conditions
+import com.raival.compose.file.explorer.common.extension.drawableToBitmap
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.hasParent
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.common.extension.toFormattedSize
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType
+import java.io.File
+
+class LocalFileHolder(val file: File) : ContentHolder() {
+ private var folderCount = 0
+ private var fileCount = 0
+ private var cachedLastModified = -1L
+
+ override val displayName: String by lazy { file.name }
+
+ override val details by lazy {
+ val separator = " | "
+ buildString {
+ append(lastModified.toFormattedDate())
+ if (file.isDirectory) {
+ if (globalClass.preferencesManager.fileListPrefs.showFolderContentCount && file.canRead()) {
+ append(separator)
+ append(getFormattedFileCount())
+ }
+ } else {
+ append(separator)
+ append(file.length().toFormattedSize())
+ append(separator)
+ append(file.extension)
+ }
+ }
+ }
+
+ override val isFolder: Boolean by lazy { file.isDirectory }
+
+ override val lastModified: Long
+ get() = file.lastModified().also {
+ if (cachedLastModified == -1L) cachedLastModified = it
+ }
+
+ override val size: Long by lazy { file.length() }
+
+ override val icon: Any = file
+
+ override val iconPlaceholder: Int by lazy { getContentIconPlaceholderResource() }
+
+ override val uniquePath: String by lazy { file.absolutePath }
+
+ override val extension: String by lazy { file.extension.lowercase() }
+
+ override val canAddNewContent: Boolean = true
+
+ override val canRead: Boolean by lazy { file.canRead() }
+
+ override val canWrite: Boolean by lazy { file.canWrite() }
+
+ val mimeType by lazy { file.mimeType ?: anyFileType }
+
+ val basePath by lazy { file.getBasePath(globalClass) }
+
+ override fun isValid(): Boolean = file.exists()
+
+ override suspend fun listContent(): ArrayList {
+ folderCount = 0
+ fileCount = 0
+
+ return arrayListOf().apply {
+ file.listFiles()?.forEach { add(LocalFileHolder(it)) }
+ }
+ }
+
+ override fun getParent(): LocalFileHolder? = file.parentFile?.let { LocalFileHolder(it) }
+
+ override fun open(
+ context: Context,
+ anonymous: Boolean,
+ skipSupportedExtensions: Boolean,
+ customMimeType: String?
+ ) {
+ if (!skipSupportedExtensions && handleSupportedFiles(context)) {
+ return
+ }
+
+ Intent(Intent.ACTION_VIEW).let { newIntent ->
+ newIntent.setDataAndType(
+ createUri(),
+ customMimeType ?: if (anonymous) anyFileType else file.mimeType
+ )
+
+ newIntent.addFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK
+ or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
+ or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
+ or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+
+ try {
+ context.startActivity(newIntent)
+ } catch (_: ActivityNotFoundException) {
+ if (!anonymous) {
+ open(context, anonymous = true, skipSupportedExtensions = true, null)
+ } else {
+ globalClass.showMsg(R.string.no_app_can_open_file)
+ }
+ } catch (e: Exception) {
+ with(globalClass) {
+ logger.logError(e)
+ showMsg(getString(R.string.failed_to_open_this_file))
+ }
+ }
+ }
+ }
+
+ override fun getContentCount(): ContentCount {
+ if (fileCount == 0 && folderCount == 0) {
+ file.listFiles()?.let { list ->
+ list.forEach {
+ if (it.isFile) fileCount++ else folderCount++
+ }
+ }
+ }
+
+ return ContentCount(fileCount, folderCount)
+ }
+
+ override fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) {
+ File(file, name).let { newFile ->
+ if (newFile.createNewFile()) {
+ onCreated(LocalFileHolder(newFile))
+ return
+ }
+ }
+ onCreated(null)
+ }
+
+ override fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) {
+ File(file, name).let { newFolder ->
+ if (newFolder.exists() || newFolder.mkdir()) {
+ onCreated(LocalFileHolder(newFolder))
+ return
+ }
+ }
+ onCreated(null)
+ }
+
+ override fun findFile(name: String): LocalFileHolder? {
+ File(file, name).let {
+ if (it.exists()) {
+ return LocalFileHolder(it)
+ }
+ }
+ return null
+ }
+
+ fun exists() = isValid()
+
+ fun hasSourceChanged() = cachedLastModified != -1L && lastModified != cachedLastModified
+
+ fun getAppsHandlingFile(mimeType: String = emptyString): List {
+ val packageManager: PackageManager = globalClass.packageManager
+
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(createUri(), mimeType.ifEmpty { this@LocalFileHolder.mimeType })
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT)
+ }
+
+ val appsList = ArrayList()
+
+ packageManager.queryIntentActivities(
+ intent,
+ PackageManager.MATCH_ALL
+ ).onEach {
+ globalClass.grantUriPermission(
+ it.activityInfo.packageName,
+ createUri(),
+ Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ appsList.add(
+ OpenWithActivityHolder(
+ label = it.activityInfo.loadLabel(packageManager).toString(),
+ name = it.activityInfo.name,
+ packageName = it.activityInfo.packageName,
+ icon = it.activityInfo.loadIcon(packageManager).drawableToBitmap(),
+ )
+ )
+ }
+
+ return appsList
+ }
+
+ fun openFileWithPackage(context: Context, packageName: String, className: String) {
+ val uri = createUri()
+
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, mimeType)
+ addFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK
+ or Intent.FLAG_ACTIVITY_NEW_DOCUMENT
+ or Intent.FLAG_ACTIVITY_MULTIPLE_TASK
+ or Intent.FLAG_GRANT_READ_URI_PERMISSION
+ or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ )
+ setPackage(packageName)
+ setClassName(packageName, className)
+ }
+
+ if (intent.resolveActivity(globalClass.packageManager) != null) {
+ context.startActivity(intent)
+ } else {
+ globalClass.showMsg("No app found to open this file.")
+ }
+ }
+
+ fun writeText(text: String) {
+ file.writeText(text)
+ }
+
+ fun appendText(text: String) {
+ file.appendText(text)
+ }
+
+ fun readText() = file.readText()
+
+ private fun handleSupportedFiles(context: Context): Boolean {
+ if (FileMimeType.conditions {
+ codeFileType.contains(extension) || editableFileType.contains(
+ extension
+ )
+ }) {
+ globalClass.textEditorManager.openTextEditor(
+ this,
+ context
+ )
+ return true
+ }
+
+ if (FileMimeType.supportedArchiveFileType.contains(extension)) {
+ globalClass.zipManager.openArchive(this)
+ return true
+ }
+
+ return false
+ }
+
+ private fun createUri() = FileProvider.getUriForFile(
+ globalClass,
+ "com.raival.compose.file.explorer.provider",
+ file
+ )
+
+ fun hasParent(parent: LocalFileHolder): Boolean =
+ file.absolutePath.hasParent(parent.file.absolutePath)
+
+ fun getFormattedFileCount(): String {
+ val contentCount = getContentCount()
+
+ return getFormattedFileCount(
+ contentCount.files,
+ contentCount.folders
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/RootFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/RootFileHolder.kt
new file mode 100644
index 00000000..5cdf1a56
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/RootFileHolder.kt
@@ -0,0 +1,59 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.holder
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount
+import java.io.File
+
+class RootFileHolder : ContentHolder() {
+ companion object {
+ const val rootDir = "/"
+ }
+
+ val virtualRootContent = listOf(
+ "/acct", "/apex", "/bin", "/cache", "/config", "/data", "/dev", "/etc",
+ "/mnt", "/odm", "/oem", "/proc", "/product", "/sbin", "/sdcard",
+ "/storage", "/system", "/vendor"
+ )
+
+ var contentsCount = ContentCount()
+ val content = arrayListOf()
+
+ override val uniquePath = rootDir
+ override val displayName = globalClass.getString(R.string.root_dir)
+ override val details = emptyString
+ override val icon = R.drawable.baseline_folder_24
+ override val iconPlaceholder = R.drawable.baseline_folder_24
+ override val isFolder = true
+ override val lastModified = 0L
+ override val size = 0L
+ override val extension = emptyString
+ override val canAddNewContent = false
+ override val canRead = true
+ override val canWrite = false
+
+ override suspend fun listContent(): ArrayList {
+ val content = ArrayList()
+
+ virtualRootContent.forEach { item ->
+ File(item).let { file ->
+ if (file.exists()) {
+ content.add(LocalFileHolder(file))
+ }
+ }
+ }
+
+ contentsCount = ContentCount(folders = content.size)
+
+ return content
+ }
+
+ override fun getParent() = null
+
+ override fun getContentCount() = contentsCount
+
+ override fun findFile(name: String) = content.find { it.displayName == name }
+
+ override fun isValid() = true
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDeviceHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDevice.kt
similarity index 68%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDeviceHolder.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDevice.kt
index 65fecc8e..ef92e420 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDeviceHolder.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/StorageDevice.kt
@@ -1,9 +1,9 @@
package com.raival.compose.file.explorer.screen.main.tab.files.holder
-data class StorageDeviceHolder(
- val documentHolder: DocumentHolder,
+data class StorageDevice(
+ val contentHolder: ContentHolder,
val title: String,
val totalSize: Long,
val usedSize: Long,
val type: Int
-)
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/VirtualFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/VirtualFileHolder.kt
new file mode 100644
index 00000000..747f7e57
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/VirtualFileHolder.kt
@@ -0,0 +1,95 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.holder
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getArchiveFiles
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getAudioFiles
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getBookmarks
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getDocumentFiles
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getImageFiles
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getRecentFiles
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider.getVideoFiles
+import kotlinx.coroutines.runBlocking
+
+class VirtualFileHolder(val type: Int) : ContentHolder() {
+ private var fileCount = 0
+ private val contentList = arrayListOf()
+
+ override val displayName = when (type) {
+ BOOKMARKS -> globalClass.getString(R.string.bookmarks)
+ AUDIO -> globalClass.getString(R.string.audios)
+ VIDEO -> globalClass.getString(R.string.videos)
+ IMAGE -> globalClass.getString(R.string.images)
+ ARCHIVE -> globalClass.getString(R.string.archives)
+ DOCUMENT -> globalClass.getString(R.string.documents)
+ RECENT -> globalClass.getString(R.string.recent_files)
+ else -> globalClass.getString(R.string.unknown)
+ }
+
+ override val details = emptyString
+
+ override val icon = emptyString
+
+ override val isFolder = true
+
+ override val lastModified = 0L
+
+ override val size = 0L
+
+ override val uniquePath = displayName
+
+ override val extension = emptyString
+
+ override fun isValid() = true
+
+ override fun getParent() = null
+
+ override val iconPlaceholder = R.drawable.baseline_folder_24
+
+ override suspend fun listContent() = when (type) {
+ BOOKMARKS -> getBookmarks()
+ AUDIO -> getAudioFiles()
+ VIDEO -> getVideoFiles()
+ IMAGE -> getImageFiles()
+ ARCHIVE -> getArchiveFiles()
+ DOCUMENT -> getDocumentFiles()
+ RECENT -> getRecentFiles()
+ else -> arrayListOf()
+ }.also {
+ contentList.apply {
+ clear()
+ addAll(it)
+ }
+ fileCount = it.size
+ }
+
+ override fun findFile(name: String): ContentHolder? {
+ if (contentList.isEmpty()) {
+ runBlocking {
+ listContent()
+ }
+ }
+
+ return contentList.find { it.displayName == name }
+ }
+
+ override val canAddNewContent = false
+
+ override val canRead = true
+
+ override val canWrite = false
+
+ override fun getContentCount() = ContentCount(files = fileCount)
+
+ companion object {
+ const val BOOKMARKS = 0
+ const val AUDIO = 1
+ const val VIDEO = 2
+ const val IMAGE = 3
+ const val ARCHIVE = 4
+ const val DOCUMENT = 5
+ const val RECENT = 6
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ZipFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ZipFileHolder.kt
new file mode 100644
index 00000000..582cdbcb
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/holder/ZipFileHolder.kt
@@ -0,0 +1,168 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.holder
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.common.extension.toFormattedSize
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.ContentCount
+import com.raival.compose.file.explorer.screen.main.tab.files.zip.ZipTree
+import com.raival.compose.file.explorer.screen.main.tab.files.zip.model.ZipNode
+import kotlinx.coroutines.runBlocking
+import net.lingala.zip4j.ZipFile
+import net.lingala.zip4j.model.ZipParameters
+import java.io.ByteArrayInputStream
+import java.io.File
+
+class ZipFileHolder(
+ val zipTree: ZipTree,
+ val node: ZipNode,
+) : ContentHolder() {
+ override val uniquePath = node.path
+ override val displayName = node.name
+ override val details by lazy { createDetails() }
+
+ override val icon = if (node.isDirectory)
+ R.drawable.baseline_folder_24 else R.drawable.unknown_file_extension
+
+ override val iconPlaceholder = if (node.isDirectory)
+ R.drawable.baseline_folder_24 else R.drawable.unknown_file_extension
+
+ override val isFolder = node.isDirectory
+ override val lastModified = node.lastModified
+ override val size = node.size
+ override val extension = node.extension
+ override val canRead = true
+ override val canWrite = true
+ override val canAddNewContent = true
+
+ private var filesCount = 0
+ private var foldersCount = 0
+
+ private var contentListCount = ContentCount()
+
+ override suspend fun listContent(): ArrayList {
+ filesCount = 0
+ foldersCount = 0
+
+ if (!zipTree.isReady) {
+ zipTree.prepare()
+ }
+
+ // In case the tab reloaded with new content, the `node` linked to this holder will have the
+ // old data, so we need to make sure that the content is up-to-date.
+ // This also somewhat apply to the LocalFileHolder, but that get the information of a 'File'
+ // which will show latest changes (except the cached stuff).
+ val newNode = zipTree.findNodeByPath(uniquePath)
+
+ if (newNode == null) return arrayListOf()
+
+ return newNode.children
+ .map {
+ ZipFileHolder(zipTree, it).also {
+ if (it.node.isDirectory) foldersCount++ else filesCount++
+ }
+ }
+ .toCollection(arrayListOf()).also {
+ contentListCount = ContentCount(filesCount, foldersCount)
+ }
+ }
+
+ override fun getParent(): ContentHolder? {
+ if (!node.path.contains(File.separator) && node.path.isNotEmpty()) {
+ return ZipFileHolder(zipTree, zipTree.getRootNode())
+ }
+
+ var parentPath = node.parentPath
+
+ while (parentPath.isNotEmpty()) {
+ val parentNode = zipTree.findNodeByPath(parentPath)
+
+ if (parentNode != null) {
+ return ZipFileHolder(zipTree, parentNode)
+ }
+
+ parentPath = ZipNode(emptyString, parentPath).parentPath
+ }
+
+ return zipTree.source.getParent()
+ }
+
+ override fun createSubFile(name: String, onCreated: (ContentHolder?) -> Unit) {
+ val path = "${if (uniquePath.isEmpty()) emptyString else "$uniquePath/"}$name"
+ val params = ZipParameters().apply {
+ fileNameInZip = path
+ isOverrideExistingFilesInZip = false
+ }
+
+ ZipFile(zipTree.source.file).use { zipFile ->
+ zipFile.addStream(ByteArrayInputStream(ByteArray(0)), params)
+ }
+
+ zipTree.prepare()
+
+ zipTree.findNodeByPath(path)?.let {
+ onCreated(ZipFileHolder(zipTree, it))
+ } ?: onCreated(null)
+ }
+
+ override fun createSubFolder(name: String, onCreated: (ContentHolder?) -> Unit) {
+ val path = "${if (uniquePath.isEmpty()) emptyString else "$uniquePath/"}$name/"
+ val params = ZipParameters().apply {
+ fileNameInZip = path
+ isOverrideExistingFilesInZip = false
+ }
+
+ ZipFile(zipTree.source.file).use { zipFile ->
+ zipFile.addStream(ByteArrayInputStream(ByteArray(0)), params)
+ }
+
+ zipTree.prepare()
+
+ zipTree.findNodeByPath(path.trimEnd('/'))?.let {
+ onCreated(ZipFileHolder(zipTree, it))
+ } ?: onCreated(null)
+ }
+
+ override fun getContentCount() = contentListCount
+
+ override fun findFile(name: String) = node.children.find { it.name == name }?.let {
+ ZipFileHolder(zipTree, it)
+ }
+
+ override fun isValid() = true
+
+ private fun createDetails(): String {
+ val separator = " | "
+ return buildString {
+ append(node.lastModified.toFormattedDate())
+ if (node.isDirectory) {
+ if (globalClass.preferencesManager.fileListPrefs.showFolderContentCount) {
+ append(separator)
+ append(getFormattedFileCount())
+ }
+ } else {
+ append(separator)
+ append(node.size.toFormattedSize())
+ append(separator)
+ append(node.extension)
+ }
+ }
+ }
+
+ private fun getFormattedFileCount(): String {
+ if (filesCount == 0 && foldersCount == 0) {
+ runBlocking {
+ listContent().forEach {
+ if (it.node.isDirectory) foldersCount++
+ else filesCount++
+ }
+ }
+ }
+
+ return getFormattedFileCount(
+ filesCount,
+ foldersCount
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Action.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Action.kt
deleted file mode 100644
index 13499d42..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Action.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.misc
-
-open class Action(
- var due: Boolean = true,
- var autoDismiss: Boolean = true,
- var isDone: Boolean = false,
- var action: () -> Unit = {}
-)
-
-class UpdateAction(due: Boolean) : Action(due)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt
index ca5c0392..a33e911e 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Comparator.kt
@@ -1,9 +1,9 @@
package com.raival.compose.file.explorer.screen.main.tab.files.misc
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
import java.util.Locale
-val sortFoldersFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder ->
+val sortFoldersFirst = Comparator { file1: ContentHolder, file2: ContentHolder ->
if (file1.isFolder && !file2.isFolder) {
return@Comparator -1
} else if (!file1.isFolder && file2.isFolder) {
@@ -13,7 +13,7 @@ val sortFoldersFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder
}
}
-val sortFilesFirst = Comparator { file2: DocumentHolder, file1: DocumentHolder ->
+val sortFilesFirst = Comparator { file2: ContentHolder, file1: ContentHolder ->
if (file1.isFolder && !file2.isFolder) {
return@Comparator -1
} else if (!file1.isFolder && file2.isFolder) {
@@ -23,26 +23,26 @@ val sortFilesFirst = Comparator { file2: DocumentHolder, file1: DocumentHolder -
}
}
-val sortOlderFirst = Comparator.comparingLong { obj: DocumentHolder -> obj.lastModified }
+val sortOlderFirst = Comparator.comparingLong { obj: ContentHolder -> obj.lastModified }
-val sortNewerFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder ->
+val sortNewerFirst = Comparator { file1: ContentHolder, file2: ContentHolder ->
file2.lastModified.compareTo(file1.lastModified)
}
-val sortName = Comparator.comparing { file: DocumentHolder ->
- file.getName().lowercase(Locale.getDefault())
+val sortName = Comparator.comparing { file: ContentHolder ->
+ file.displayName.lowercase(Locale.getDefault())
}
-val sortNameRev = Comparator { file1: DocumentHolder, file2: DocumentHolder ->
- file2.getName().lowercase(Locale.getDefault()).compareTo(
- file1.getName().lowercase(
+val sortNameRev = Comparator { file1: ContentHolder, file2: ContentHolder ->
+ file2.displayName.lowercase(Locale.getDefault()).compareTo(
+ file1.displayName.lowercase(
Locale.getDefault()
)
)
}
-val sortSmallerFirst = Comparator.comparingLong { obj: DocumentHolder -> obj.fileSize }
+val sortSmallerFirst = Comparator.comparingLong { obj: ContentHolder -> obj.size }
-val sortLargerFirst = Comparator { file1: DocumentHolder, file2: DocumentHolder ->
- file2.fileSize.compareTo(file1.fileSize)
+val sortLargerFirst = Comparator { file1: ContentHolder, file2: ContentHolder ->
+ file2.size.compareTo(file1.size)
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt
index 95d178ab..f50c0372 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/Constant.kt
@@ -10,4 +10,10 @@ object Language {
const val LANGUAGE_JAVA = 1
const val LANGUAGE_KOTLIN = 2
const val LANGUAGE_JSON = 3
+}
+
+object StorageDeviceType {
+ const val ROOT = 0
+ const val INTERNAL_STORAGE = 1
+ const val EXTERNAL_STORAGE = 2
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentCount.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentCount.kt
new file mode 100644
index 00000000..fd1c988a
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentCount.kt
@@ -0,0 +1,6 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.misc
+
+data class ContentCount(
+ val files: Int = 0,
+ val folders: Int = 0
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentProperty.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentProperty.kt
new file mode 100644
index 00000000..d407f7a3
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/ContentProperty.kt
@@ -0,0 +1,7 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.misc
+
+data class ContentProperty(
+ val label: String,
+ val copiable: Boolean = true,
+ val updateValue: () -> String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt
index c754817c..0c851217 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/FileMimeType.kt
@@ -29,138 +29,49 @@ object FileMimeType {
val fontFileType = arrayOf("ttf", "otf")
@JvmField
- val vectorFileType =
- arrayOf("svg", "ai", "eps", "pdf", "dxf", "wmf", "emf", "cdr", "odg", "swf")
+ val vectorFileType = arrayOf(
+ "svg", "ai", "eps", "pdf", "dxf",
+ "wmf", "emf", "cdr", "odg", "swf"
+ )
@JvmField
val archiveFileType = arrayOf(
- "zip",
- "7z",
- "tar",
- "jar",
- "gz",
- "xz",
- "obb",
- "rar",
- "iso",
- "bz2",
- "tgz",
- "tbz2",
- "lz",
- "lzma",
+ "zip", "7z", "tar", "jar", "gz", "xz", "obb", "rar",
+ "iso", "bz2", "tgz", "tbz2", "lz", "lzma"
)
+ @JvmField
+ val supportedArchiveFileType = arrayOf("zip", "jar", "apk", "apks")
+
@JvmField
val videoFileType = arrayOf(
- "mp4",
- "mov",
- "avi",
- "mkv",
- "wmv",
- "m4v",
- "3gp",
- "webm",
- "flv",
- "mpeg",
- "mpg",
- "ogv",
- "mxf",
- "vob",
- "ts"
+ "mp4", "mov", "avi", "mkv", "wmv", "m4v", "3gp",
+ "webm", "flv", "mpeg", "mpg", "ogv", "mxf", "vob", "ts"
)
@JvmField
val codeFileType = arrayOf(
- javaFileType,
- "xml",
- "py",
- "css",
- kotlinFileType,
- "cs",
- "xml",
- jsonFileType,
- "html",
- "js",
- "ts",
- "php",
- "rb",
- "pl",
- "sh",
- "cpp",
- "c",
- "h",
- "swift",
- "go",
- "rs",
- "scala",
- "sql",
- "r",
- "md",
- "ini",
- "yaml",
- "yml"
+ javaFileType, "xml", "py", "css", kotlinFileType, "cs", "xml", jsonFileType, "html",
+ "js", "ts", "php", "rb", "pl", "sh", "cpp", "c", "h", "swift", "go", "rs",
+ "scala", "sql", "r", "md", "ini", "yaml", "yml"
)
@JvmField
val editableFileType = arrayOf(
- "txt",
- "text",
- "log",
- "dsc",
- "apt",
- "rtf",
- "rtx",
- "md",
- "csv",
- "tsv",
- "ini",
- "conf",
- "cfg",
- "nfo",
- "json",
- "xml"
+ "txt", "text", "log", "dsc", "apt", "rtf", "rtx", "md",
+ "csv", "tsv", "ini", "conf", "cfg", "nfo", "json", "xml"
)
@JvmField
val imageFileType = arrayOf(
- "png",
- "jpeg",
- "jpg",
- "heic",
- "tiff",
- "gif",
- "webp",
- svgFileType,
- "bmp",
- "raw",
- "cr2",
- "nef",
- "orf",
- "sr2",
- "psd",
- "ai",
- "eps"
+ "png", "jpeg", "jpg", "heic", "tiff", "gif", "webp", svgFileType,
+ "bmp", "raw", "cr2", "nef", "orf", "sr2", "psd", "ai", "eps"
)
@JvmField
val audioFileType = arrayOf(
- "mp3",
- "4mp",
- "aup",
- "ogg",
- "3ga",
- "m4b",
- "wav",
- "acc",
- "m4a",
- "flac",
- "aac",
- "wma",
- "aiff",
- "amr",
- "midi",
- "mid",
- "opus"
+ "mp3", "4mp", "aup", "ogg", "3ga", "m4b", "wav", "acc",
+ "m4a", "flac", "aac", "wma", "aiff", "amr", "midi", "mid", "opus"
)
@JvmField
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/MergeHandler.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/MergeHandler.kt
deleted file mode 100644
index 218952ab..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/MergeHandler.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.misc
-
-import android.content.Context
-import com.android.apksig.ApkSigner
-import com.android.apksig.ApkSigner.SignerConfig
-import com.android.apksig.KeyConfig
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.reandroid.apkeditor.merge.Merger
-import com.reandroid.apkeditor.merge.MergerOptions
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-import java.io.File
-import java.io.IOException
-import java.io.InputStream
-import java.security.KeyFactory
-import java.security.NoSuchAlgorithmException
-import java.security.SignatureException
-import java.security.cert.CertificateFactory
-import java.security.cert.X509Certificate
-import java.security.spec.InvalidKeySpecException
-import java.security.spec.PKCS8EncodedKeySpec
-
-class MergeHandler(private val context: Context) {
-
- @Throws(IOException::class)
- fun executeMerge(inputFilePath: String, outputFilePath: String, onProgressUpdate: (Float, String) -> Unit) {
- val options = MergerOptions().apply {
- inputFile = File(inputFilePath)
- outputFile = File(outputFilePath)
- }
- val merger = Merger(options)
- val tasks = listOf(
- context.getString(R.string.initializing_merge) to 0.1f,
- context.getString(R.string.extracting_files) to 0.3f,
- context.getString(R.string.merging_modules) to 0.6f,
- context.getString(R.string.finalizing_merge) to 0.8f,
- )
- for ((message, progress) in tasks) {
- Thread.sleep(500)
- onProgressUpdate(progress, message)
- }
- merger.runCommand()
- }
-
- @Throws(IOException::class, NoSuchAlgorithmException::class, InvalidKeySpecException::class, SignatureException::class)
- private fun signApk(apkFilePath: String, outFilePath: String, keyInputStream: InputStream, certInputStream: InputStream) {
- val privateKey = keyInputStream.use { inputStream ->
- val keyBytes = inputStream.readBytes()
- val keySpec = PKCS8EncodedKeySpec(keyBytes)
- val keyFactory = KeyFactory.getInstance("RSA")
- keyFactory.generatePrivate(keySpec)
- }
-
- val certificate = certInputStream.use { inputStream ->
- val certificateFactory = CertificateFactory.getInstance("X.509")
- certificateFactory.generateCertificate(inputStream) as X509Certificate
- }
-
- val keyConfig = KeyConfig.Jca(privateKey)
-
- val signerConfig = SignerConfig.Builder(
- "Android",
- keyConfig,
- listOf(certificate)
- ).build()
-
- val apkSigner = ApkSigner.Builder(listOf(signerConfig))
- .setInputApk(File(apkFilePath))
- .setOutputApk(File(outFilePath))
- .setV1SigningEnabled(true)
- .setV2SigningEnabled(true)
- .build()
-
- apkSigner.sign()
- }
-
- fun mergeApks(tab: FilesTab, apkFile: DocumentHolder, doSign: Boolean, onSuccess: () -> Unit, onError: (String) -> Unit) {
- CoroutineScope(Dispatchers.IO).launch {
- try {
- val fileExtension = apkFile.fileExtension
- val fileName = apkFile.getName().removeSuffix(".$fileExtension")
- val filePath = apkFile.path
- val fileDir = apkFile.parent?.path
- val outputFilePath = "$fileDir/$fileName.apk"
- val mergeHandler = MergeHandler(context)
- withContext(Dispatchers.Main) {
- tab.taskDialog.showTaskDialog = true
- tab.taskDialog.taskDialogInfo = context.getString(R.string.merging_apks)
- tab.taskDialog.taskDialogTitle = context.getString(R.string.merging)
- tab.taskDialog.taskDialogSubtitle = context.getString(R.string.merging_apks)
- tab.taskDialog.showTaskDialogProgressbar = true
- tab.taskDialog.taskDialogProgress = 0f
- }
- mergeHandler.executeMerge(filePath, outputFilePath) { progress, message ->
- launch(Dispatchers.Main) {
- tab.taskDialog.taskDialogProgress = progress
- tab.taskDialog.taskDialogSubtitle = message
- }
- }
- val outputFile = File(outputFilePath)
- if (!outputFile.exists()) {
- withContext(Dispatchers.Main) { onError(context.getString(R.string.merge_failed)) }
- return@launch
- }
-
- if (doSign) {
- // TODO: Custom KeyStore
- val keyFilePath = tab.assetManager.open("keystore/testkey.pk8")
- val certFilePath = tab.assetManager.open("keystore/testkey.x509.pem")
- val signedApkPath = outputFilePath.replace(".apk", "-signed.apk")
-
- tab.taskDialog.taskDialogSubtitle = context.getString(R.string.signing_apk)
- tab.taskDialog.taskDialogProgress = 0.9f
- signApk(outputFilePath, signedApkPath, keyFilePath, certFilePath)
-
- if (!File(signedApkPath).exists()) {
- withContext(Dispatchers.Main) { onError(context.getString(R.string.signing_failed)) }
- return@launch
- }
- if (File(outputFilePath).exists()) {
- File(outputFilePath).delete()
- }
- }
-
- withContext(Dispatchers.Main) { onSuccess() }
- } catch (e: Exception) {
- e.printStackTrace()
- withContext(Dispatchers.Main) {
- onError(context.getString(R.string.merge_failed_with_error, e.message))
- delay(500)
- tab.taskDialog.showTaskDialog = false
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/StorageDeviceType.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/StorageDeviceType.kt
deleted file mode 100644
index f50792ac..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/misc/StorageDeviceType.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.misc
-
-const val ROOT = 0
-const val INTERNAL_STORAGE = 1
-const val EXTERNAL_STORAGE = 2
-const val UNKNOWN = 3
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/ContentPropertiesProvider.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/ContentPropertiesProvider.kt
new file mode 100644
index 00000000..6b2a149a
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/ContentPropertiesProvider.kt
@@ -0,0 +1,380 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.provider
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.yield
+import java.io.File
+import java.io.FileInputStream
+import java.math.BigInteger
+import java.nio.file.Files
+import java.security.MessageDigest
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+data class CalculationProgress(
+ val isCalculating: Boolean = false,
+ val current: Long = 0,
+ val total: Long = 0,
+ val message: String = emptyString
+)
+
+sealed interface PropertiesState {
+ object Loading : PropertiesState
+
+ data class SingleContentProperties(
+ val name: String,
+ val path: String,
+ val type: String,
+ val lastModified: String,
+ val size: String,
+ val permissions: String,
+ val owner: String,
+ val contentCount: StateFlow,
+ val checksum: StateFlow,
+ val contentProgress: StateFlow,
+ val checksumProgress: StateFlow,
+ ) : PropertiesState
+
+ data class MultipleContentProperties(
+ val selectedFileCount: Int,
+ val totalFileCount: StateFlow,
+ val totalSize: StateFlow,
+ val countProgress: StateFlow,
+ val sizeProgress: StateFlow,
+ ) : PropertiesState
+}
+
+data class ContentPropertiesUiState(
+ val details: PropertiesState = PropertiesState.Loading
+)
+
+class ContentPropertiesProvider(private val contentHolders: List) {
+ private val _uiState = MutableStateFlow(ContentPropertiesUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val calculationScope = CoroutineScope(IO)
+ private val activeJobs = mutableSetOf()
+
+ init {
+ when {
+ contentHolders.isEmpty() -> {
+ // Handle empty case - could show error state
+ }
+
+ contentHolders.size == 1 -> {
+ loadSingleContentDetails(contentHolders.first())
+ }
+
+ else -> {
+ loadMultipleContentDetails(contentHolders)
+ }
+ }
+ }
+
+ fun cleanup() {
+ activeJobs.forEach { it.cancel() }
+ calculationScope.cancel()
+ }
+
+ private fun loadSingleContentDetails(file: ContentHolder) {
+ val contentCountFlow = MutableStateFlow(globalClass.getString(R.string.not_available))
+ val checksumFlow = MutableStateFlow(
+ if (file.isFolder) globalClass.getString(R.string.not_available) else globalClass.getString(
+ R.string.calculating
+ )
+ )
+ val contentProgressFlow = MutableStateFlow(CalculationProgress())
+ val checksumProgressFlow = MutableStateFlow(CalculationProgress())
+
+ _uiState.update {
+ it.copy(
+ details = PropertiesState.SingleContentProperties(
+ name = file.displayName.ifEmpty { globalClass.getString(R.string.root) },
+ path = file.getParent()?.uniquePath ?: File.separator,
+ type = determineFileType(file),
+ lastModified = formatTimestamp(file.lastModified),
+ size = if (file.isFolder) globalClass.getString(R.string.calculating) else formatFileSize(
+ file.size
+ ),
+ permissions = getPermissions(file),
+ owner = getOwner(file),
+ contentCount = contentCountFlow,
+ checksum = checksumFlow,
+ contentProgress = contentProgressFlow,
+ checksumProgress = checksumProgressFlow
+ )
+ )
+ }
+
+ if (file.isFolder) {
+ val job = calculationScope.launch {
+ contentProgressFlow.value = CalculationProgress(isCalculating = true)
+ try {
+ val (fileCount, folderCount, totalSize) = calculateDirectoryStats(file) { current ->
+ contentProgressFlow.value = CalculationProgress(
+ isCalculating = true,
+ current = current
+ )
+ }
+ contentCountFlow.value =
+ globalClass.getString(R.string.files_folders_count, fileCount, folderCount)
+
+ // Update size for directories
+ _uiState.update { state ->
+ val currentDetails =
+ state.details as? PropertiesState.SingleContentProperties
+ currentDetails?.let {
+ state.copy(
+ details = it.copy(size = formatFileSize(totalSize))
+ )
+ } ?: state
+ }
+ } catch (_: Exception) {
+ contentCountFlow.value = globalClass.getString(R.string.error_calculating)
+ } finally {
+ contentProgressFlow.value = CalculationProgress(isCalculating = false)
+ }
+ }
+ activeJobs.add(job)
+ } else if (file is LocalFileHolder) {
+ // Calculate checksum with progress
+ val job = calculationScope.launch {
+ checksumProgressFlow.value = CalculationProgress(isCalculating = true)
+ try {
+ val checksum = calculateMD5WithProgress(file) { bytesProcessed, totalBytes ->
+ checksumProgressFlow.value = CalculationProgress(
+ isCalculating = true,
+ current = bytesProcessed,
+ total = totalBytes
+ )
+ }
+ checksumFlow.value = checksum
+ } catch (_: Exception) {
+ checksumFlow.value = globalClass.getString(R.string.error_calculating)
+ } finally {
+ checksumProgressFlow.value = CalculationProgress(isCalculating = false)
+ }
+ }
+ activeJobs.add(job)
+ } else {
+ checksumFlow.value = globalClass.getString(R.string.not_available)
+ }
+ }
+
+ private fun loadMultipleContentDetails(files: List) {
+ val totalFileCountFlow = MutableStateFlow(globalClass.getString(R.string.calculating))
+ val totalSizeFlow = MutableStateFlow(globalClass.getString(R.string.calculating))
+ val countProgressFlow = MutableStateFlow(CalculationProgress())
+ val sizeProgressFlow = MutableStateFlow(CalculationProgress())
+
+ _uiState.update {
+ it.copy(
+ details = PropertiesState.MultipleContentProperties(
+ selectedFileCount = files.size,
+ totalFileCount = totalFileCountFlow,
+ totalSize = totalSizeFlow,
+ countProgress = countProgressFlow,
+ sizeProgress = sizeProgressFlow
+ )
+ )
+ }
+
+ val job = calculationScope.launch {
+ countProgressFlow.value =
+ CalculationProgress(isCalculating = true, total = files.size.toLong())
+ sizeProgressFlow.value =
+ CalculationProgress(isCalculating = true, total = files.size.toLong())
+
+ try {
+ var totalFiles = 0L
+ var totalFolders = 0L
+ var totalSize = 0L
+ var processedItems = 0L
+
+ files.forEach { file ->
+ if (!isActive) return@forEach
+
+ processedItems++
+ countProgressFlow.value = CalculationProgress(
+ isCalculating = true,
+ current = processedItems,
+ total = files.size.toLong()
+ )
+ sizeProgressFlow.value = CalculationProgress(
+ isCalculating = true,
+ current = processedItems,
+ total = files.size.toLong()
+ )
+
+ if (file.isFolder) {
+ val (filesCount, foldersCount, dirSize) = calculateDirectoryStats(file)
+ totalFiles += filesCount
+ totalFolders += foldersCount + 1 // +1 for the directory itself
+ totalSize += dirSize
+ } else {
+ totalFiles++
+ totalSize += file.size
+ }
+
+ // Update intermediate results
+ totalFileCountFlow.value = globalClass.getString(
+ R.string.files_folders_count,
+ totalFiles,
+ totalFolders
+ )
+ totalSizeFlow.value = formatFileSize(totalSize)
+
+ yield() // Allow cancellation
+ }
+ } catch (_: Exception) {
+ totalFileCountFlow.value = globalClass.getString(R.string.error_calculating)
+ totalSizeFlow.value = globalClass.getString(R.string.error_calculating)
+ } finally {
+ countProgressFlow.value = CalculationProgress(isCalculating = false)
+ sizeProgressFlow.value = CalculationProgress(isCalculating = false)
+ }
+ }
+ activeJobs.add(job)
+ }
+
+ private fun determineFileType(file: ContentHolder): String {
+ return when {
+ file.isFolder -> globalClass.getString(R.string.folder)
+ else -> {
+ file.extension.lowercase().ifEmpty { globalClass.getString(R.string.unknown) }
+ }
+ }
+ }
+
+ private fun formatTimestamp(millis: Long): String {
+ val sdf = SimpleDateFormat("MMM dd, yyyy • hh:mm:ss a", Locale.getDefault())
+ return sdf.format(Date(millis))
+ }
+
+ private fun formatFileSize(bytes: Long): String {
+ if (bytes < 1024) return "$bytes B"
+ val z = (63 - java.lang.Long.numberOfLeadingZeros(bytes)) / 10
+ return String.format(
+ Locale.US,
+ "%.1f %sB",
+ bytes.toDouble() / (1L shl (z * 10)),
+ " KMGTPE"[z]
+ )
+ }
+
+ private fun getPermissions(file: ContentHolder): String {
+ if (file is LocalFileHolder) {
+ return buildString {
+ append(if (file.canRead) "r" else "-")
+ append(if (file.canWrite) "w" else "-")
+ append(if (file.file.canExecute()) "x" else "-")
+ }
+ }
+
+ return globalClass.getString(R.string.not_available)
+ }
+
+ private fun getOwner(file: ContentHolder): String {
+ return try {
+ if (file is LocalFileHolder) {
+ Files.getOwner(file.file.toPath()).name
+ } else {
+ globalClass.getString(R.string.unknown)
+ }
+ } catch (_: Exception) {
+ globalClass.getString(R.string.unknown)
+ }
+ }
+
+ private suspend fun calculateDirectoryStats(
+ directory: ContentHolder,
+ onProgress: ((Long) -> Unit)? = null
+ ): Triple {
+ var fileCount = 0L
+ var folderCount = 0L
+ var totalSize = 0L
+ var processed = 0L
+
+ suspend fun processDirectory(dir: ContentHolder) {
+ if (!currentCoroutineContext().isActive) return
+
+ try {
+ dir.listContent().forEach { file ->
+ if (!currentCoroutineContext().isActive) return@forEach
+
+ processed++
+ onProgress?.invoke(processed)
+
+ if (file.isFolder) {
+ folderCount++
+ processDirectory(file)
+ } else {
+ fileCount++
+ totalSize += file.size
+ }
+
+ if (processed % 50 == 0L) {
+ yield() // Periodically yield for cancellation
+ }
+ }
+ } catch (_: SecurityException) {
+ // Skip directories we can't access
+ }
+ }
+
+ processDirectory(directory)
+ return Triple(fileCount, folderCount, totalSize)
+ }
+
+ private suspend fun calculateMD5WithProgress(
+ file: LocalFileHolder,
+ onProgress: (Long, Long) -> Unit
+ ): String {
+ if (file.isFolder) return globalClass.getString(R.string.not_available)
+
+ return withContext(IO) {
+ val md = MessageDigest.getInstance("MD5")
+ val fileSize = file.size
+ var bytesProcessed = 0L
+
+ try {
+ FileInputStream(file.file).use { fis ->
+ val buffer = ByteArray(8192)
+ var read: Int
+ while (fis.read(buffer).also { read = it } != -1) {
+ if (!currentCoroutineContext().isActive) break
+ md.update(buffer, 0, read)
+ bytesProcessed += read
+ onProgress(bytesProcessed, fileSize)
+
+ if (bytesProcessed % (64 * 1024) == 0L) {
+ yield() // Yield every 64KB
+ }
+ }
+ }
+
+ val digest = md.digest()
+ val bigInt = BigInteger(1, digest)
+ bigInt.toString(16).padStart(32, '0')
+ } catch (_: Exception) {
+ globalClass.getString(R.string.error_calculating)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt
index 83a7bf56..2ee7e01f 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/provider/StorageProvider.kt
@@ -8,35 +8,26 @@ import android.os.Environment
import android.os.StatFs
import android.provider.MediaStore
import androidx.core.content.ContextCompat
-import androidx.documentfile.provider.DocumentFile
-import com.anggrayudi.storage.file.DocumentFileCompat
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDeviceHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.EXTERNAL_STORAGE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.INTERNAL_STORAGE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.ROOT
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.RootFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDevice
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.EXTERNAL_STORAGE
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.INTERNAL_STORAGE
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.ROOT
import java.io.File
object StorageProvider {
- val recentFiles = DocumentHolder.fromFile(File("/:Recent"))
- val images = DocumentHolder.fromFile(File("/:Images"))
- val videos = DocumentHolder.fromFile(File("/:Videos"))
- val audios = DocumentHolder.fromFile(File("/:Audios"))
- val documents = DocumentHolder.fromFile(File("/:Documents"))
- val archives = DocumentHolder.fromFile(File("/:Archives"))
- val bookmarks = DocumentHolder.fromFile(File("/:Bookmarks"))
+ fun getStorageDevices(context: Context): List {
+ val storageDevices = mutableListOf()
- fun getStorageDevices(context: Context): List {
- val storageDeviceHolders = mutableListOf()
-
- storageDeviceHolders.add(getPrimaryInternalStorage(context))
+ storageDevices.add(getPrimaryInternalStorage(context))
val externalDirs = getExternalStorageDirectories(context)
for (externalDir in externalDirs) {
- if (externalDir.path == Environment.getExternalStorageDirectory().path){
+ if (externalDir.path == Environment.getExternalStorageDirectory().path) {
continue
}
@@ -45,9 +36,9 @@ object StorageProvider {
val availableSize = statFs.availableBytes
val usedSize = totalSize - availableSize
- storageDeviceHolders.add(
- StorageDeviceHolder(
- DocumentHolder(DocumentFile.fromFile(externalDir)),
+ storageDevices.add(
+ StorageDevice(
+ LocalFileHolder(externalDir),
"External Storage (${externalDir.name})",
totalSize,
usedSize,
@@ -56,12 +47,12 @@ object StorageProvider {
)
}
- storageDeviceHolders.add(getRoot(context))
+ storageDevices.add(getRoot(context))
- return storageDeviceHolders
+ return storageDevices
}
- fun getRoot(context: Context): StorageDeviceHolder {
+ fun getRoot(context: Context): StorageDevice {
val externalStorageDir = Environment.getRootDirectory()
val externalStatFs = StatFs(externalStorageDir.absolutePath)
@@ -70,8 +61,8 @@ object StorageProvider {
val externalAvailableSize = externalStatFs.availableBytes
val externalUsedSize = externalTotalSize - externalAvailableSize
- return StorageDeviceHolder(
- DocumentHolder(DocumentFileCompat.fromFile(context, externalStorageDir)!!),
+ return StorageDevice(
+ RootFileHolder(),
context.getString(R.string.root_dir),
externalTotalSize,
externalUsedSize,
@@ -79,7 +70,7 @@ object StorageProvider {
)
}
- fun getPrimaryInternalStorage(context: Context): StorageDeviceHolder {
+ fun getPrimaryInternalStorage(context: Context): StorageDevice {
val externalStorageDir = Environment.getExternalStorageDirectory()
val externalStatFs = StatFs(externalStorageDir.absolutePath)
@@ -88,8 +79,8 @@ object StorageProvider {
val externalAvailableSize = externalStatFs.availableBytes
val externalUsedSize = externalTotalSize - externalAvailableSize
- return StorageDeviceHolder(
- DocumentHolder(DocumentFile.fromFile(externalStorageDir)),
+ return StorageDevice(
+ LocalFileHolder(externalStorageDir),
context.getString(R.string.internal_storage),
externalTotalSize,
externalUsedSize,
@@ -111,8 +102,8 @@ object StorageProvider {
return directories
}
- fun getDocumentFiles(): ArrayList {
- val documentFiles = ArrayList()
+ fun getDocumentFiles(): ArrayList {
+ val documentFiles = ArrayList()
val contentResolver: ContentResolver = globalClass.contentResolver
val uri: Uri = MediaStore.Files.getContentUri("external")
@@ -148,15 +139,15 @@ object StorageProvider {
while (it.moveToNext()) {
val path = it.getString(pathColumn)
- documentFiles.add(DocumentHolder.fromFile(File(path)))
+ documentFiles.add(LocalFileHolder(File(path)))
}
}
return documentFiles
}
- fun getArchiveFiles(): ArrayList {
- val archiveFiles = ArrayList()
+ fun getArchiveFiles(): ArrayList {
+ val archiveFiles = ArrayList()
val contentResolver: ContentResolver = globalClass.contentResolver
val uri: Uri = MediaStore.Files.getContentUri("external")
@@ -190,15 +181,15 @@ object StorageProvider {
while (it.moveToNext()) {
val path = it.getString(pathColumn)
- archiveFiles.add(DocumentHolder.fromFile(File(path)))
+ archiveFiles.add(LocalFileHolder(File(path)))
}
}
return archiveFiles
}
- fun getImageFiles(): ArrayList {
- val imageFiles = ArrayList()
+ fun getImageFiles(): ArrayList {
+ val imageFiles = ArrayList()
val contentResolver: ContentResolver = globalClass.contentResolver
val uri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
@@ -220,15 +211,15 @@ object StorageProvider {
while (it.moveToNext()) {
val path = it.getString(pathColumn)
- imageFiles.add(DocumentHolder.fromFile(File(path)))
+ imageFiles.add(LocalFileHolder(File(path)))
}
}
return imageFiles
}
- fun getVideoFiles(): ArrayList {
- val videoFiles = ArrayList()
+ fun getVideoFiles(): ArrayList {
+ val videoFiles = ArrayList()
val contentResolver: ContentResolver = globalClass.contentResolver
val uri: Uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
@@ -250,15 +241,18 @@ object StorageProvider {
while (it.moveToNext()) {
val path = it.getString(pathColumn)
- videoFiles.add(DocumentHolder.fromFile(File(path)))
+ videoFiles.add(LocalFileHolder(File(path)))
}
}
return videoFiles
}
- fun getAudioFiles(): ArrayList {
- val audioFiles = ArrayList()
+ fun getBookmarks() = globalClass.filesTabManager.bookmarks
+ .map { LocalFileHolder(File(it)) } as ArrayList
+
+ fun getAudioFiles(): ArrayList {
+ val audioFiles = ArrayList()
val contentResolver: ContentResolver = globalClass.contentResolver
val uri: Uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
@@ -280,7 +274,7 @@ object StorageProvider {
while (it.moveToNext()) {
val path = it.getString(pathColumn)
- audioFiles.add(DocumentHolder.fromFile(File(path)))
+ audioFiles.add(LocalFileHolder(File(path)))
}
}
@@ -290,10 +284,10 @@ object StorageProvider {
fun getRecentFiles(
recentHours: Int = 48,
limit: Int = 25
- ): ArrayList {
- val recentFiles = ArrayList()
+ ): ArrayList {
+ val recentFiles = ArrayList()
val contentResolver: ContentResolver = globalClass.contentResolver
- val showHiddenFiles = globalClass.preferencesManager.displayPrefs.showHiddenFiles
+ val showHiddenFiles = globalClass.preferencesManager.fileListPrefs.showHiddenFiles
val uri: Uri = MediaStore.Files.getContentUri("external")
@@ -324,7 +318,7 @@ object StorageProvider {
val filePath = it.getString(columnIndexPath)
val file = File(filePath)
if (file.isFile && (showHiddenFiles || !file.name.startsWith("."))) {
- recentFiles.add(DocumentHolder.fromFile(file))
+ recentFiles.add(LocalFileHolder(file))
}
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/service/ContentOperationService.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/service/ContentOperationService.kt
new file mode 100644
index 00000000..8d970ae6
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/service/ContentOperationService.kt
@@ -0,0 +1,188 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.os.IBinder
+import androidx.core.app.NotificationCompat
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.screen.main.MainActivity
+import com.raival.compose.file.explorer.screen.main.tab.files.task.TaskStatus
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import java.util.concurrent.ConcurrentHashMap
+
+class ContentOperationService : Service() {
+ private val serviceJob = SupervisorJob()
+ private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob)
+
+ // Tasks ids and whether they are running
+ private val runningTaskIds = ConcurrentHashMap()
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ startForeground(NOTIFICATION_ID, createNotification())
+ }
+
+ private fun createNotificationChannel() {
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.task_running_notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ private fun createNotification(): Notification {
+ val intent = Intent(this, MainActivity::class.java)
+ val pendingIntent = PendingIntent.getActivity(
+ this, 0, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val activeTasksCount = runningTaskIds.size
+ val userActionRequired = runningTaskIds.values.any { !it }
+
+ return NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(globalClass.getString(R.string.tasks))
+ .setContentText(
+ if (userActionRequired) getString(R.string.action_required)
+ else globalClass.getString(R.string.task_running_notification_text)
+ )
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .setNumber(activeTasksCount)
+ .build()
+ }
+
+ private fun updateNotification() {
+ val notificationManager = getSystemService(NotificationManager::class.java)
+ notificationManager.notify(NOTIFICATION_ID, createNotification())
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ intent?.let { intent ->
+ when (intent.action) {
+ ACTION_START_TASK -> {
+ val taskId = intent.getStringExtra(TASK_ID)
+ if (taskId != null) {
+ startTask(taskId)
+ }
+ }
+
+ ACTION_REMOVE_TASK -> {
+ val taskId = intent.getStringExtra(TASK_ID)
+ if (taskId != null) {
+ removeTask(taskId)
+ }
+ }
+ }
+ }
+ return START_STICKY
+ }
+
+ private fun removeTask(taskId: String) {
+ if (runningTaskIds.containsKey(taskId)) {
+ if (runningTaskIds[taskId] == true) {
+ globalClass.showMsg(R.string.task_is_running)
+ } else {
+ runningTaskIds.remove(taskId)
+ updateNotification()
+
+ // Stop service if no more tasks
+ if (runningTaskIds.isEmpty()) {
+ stopSelf()
+ }
+ }
+ }
+ }
+
+ private fun startTask(taskId: String) {
+ if (runningTaskIds.containsKey(taskId) && runningTaskIds[taskId] == true) {
+ return
+ }
+
+ val task = globalClass.taskManager.getTask(taskId)
+
+ if (task == null) {
+ return
+ }
+
+ runningTaskIds[taskId] = true
+ updateNotification()
+
+ serviceScope.launch {
+ try {
+ task.run()
+ globalClass.taskManager.handleTaskStatusChange(task, task.getCurrentStatus())
+ } catch (e: Exception) {
+ logger.logError(e)
+ globalClass.taskManager.handleTaskStatusChange(task, TaskStatus.FAILED)
+ } finally {
+ globalClass.mainActivityManager.resumeActiveTab()
+ if (task.getCurrentStatus() == TaskStatus.SUCCESS) {
+ runningTaskIds.remove(taskId)
+ } else {
+ // require action from user
+ runningTaskIds[taskId] = false
+ }
+
+ updateNotification()
+
+ // Stop service if no more tasks
+ if (runningTaskIds.isEmpty()) {
+ stopSelf()
+ }
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+
+ // Cancel all running tasks
+ runningTaskIds.keys.forEach { taskId ->
+ globalClass.taskManager.getTask(taskId)?.abortTask()
+ }
+
+ serviceJob.cancel()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ private const val TASK_ID = "taskId"
+ private const val NOTIFICATION_ID = 1297
+ private const val CHANNEL_ID = "task_background_service"
+ const val ACTION_START_TASK = "ACTION_START_TASK"
+ const val ACTION_REMOVE_TASK = "ACTION_REMOVE_TASK"
+
+ fun startNewBackgroundTask(context: Context, taskId: String) {
+ val intent = Intent(context, ContentOperationService::class.java).apply {
+ action = ACTION_START_TASK
+ putExtra(TASK_ID, taskId)
+ }
+ context.startService(intent)
+ }
+
+ fun removeBackgroundTask(context: Context, taskId: String) {
+ val intent = Intent(context, ContentOperationService::class.java).apply {
+ action = ACTION_REMOVE_TASK
+ putExtra(TASK_ID, taskId)
+ }
+ context.startService(intent)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/ApksMergeTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/ApksMergeTask.kt
new file mode 100644
index 00000000..823cd5a3
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/ApksMergeTask.kt
@@ -0,0 +1,207 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+import com.android.apksig.ApkSigner
+import com.android.apksig.KeyConfig
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.reandroid.apkeditor.merge.Merger
+import com.reandroid.apkeditor.merge.MergerOptions
+import java.io.File
+import java.security.KeyFactory
+import java.security.cert.CertificateFactory
+import java.security.cert.X509Certificate
+import java.security.spec.PKCS8EncodedKeySpec
+
+class ApksMergeTask(
+ val sourceContent: ContentHolder
+) : Task() {
+ private var parameters: ApksMergeTaskParameters? = null
+
+ override val metadata = System.currentTimeMillis().toFormattedDate().let { time ->
+ TaskMetadata(
+ id = id,
+ creationTime = time,
+ title = globalClass.resources.getString(R.string.convert_to_apk),
+ subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size),
+ displayDetails = sourceContent.displayName,
+ fullDetails = buildString {
+ append(sourceContent.displayName)
+ append("\n")
+ append(time)
+ },
+ isCancellable = false,
+ canMoveToBackground = false
+ )
+ }
+
+ override val progressMonitor = TaskProgressMonitor(
+ status = TaskStatus.PENDING,
+ taskTitle = metadata.title,
+ )
+
+ override fun getCurrentStatus() = progressMonitor.status
+
+ override fun validate() = sourceContent.isValid()
+
+ private fun markAsFailed(info: String) {
+ progressMonitor.apply {
+ status = TaskStatus.FAILED
+ summary = info
+ }
+ }
+
+ override suspend fun run() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
+ }
+ run(parameters!!)
+ }
+
+ override suspend fun run(params: TaskParameters) {
+ parameters = params as ApksMergeTaskParameters
+ progressMonitor.status = TaskStatus.RUNNING
+ protect = false
+
+ if (!sourceContent.isValid() || sourceContent !is LocalFileHolder) {
+ markAsFailed(globalClass.resources.getString(R.string.invalid_bundle_file))
+ return
+ }
+
+ progressMonitor.processName = globalClass.resources.getString(R.string.merging)
+
+ val mergedFile = mergeBundleFile(sourceContent)
+
+ // mergeBundleFile function will handle failed task
+ if (mergedFile != null && parameters!!.autoSign) {
+ progressMonitor.processName = globalClass.resources.getString(R.string.signing)
+ signApkFile(mergedFile)
+ }
+
+ if (progressMonitor.status == TaskStatus.RUNNING) {
+ progressMonitor.status = TaskStatus.SUCCESS
+ progressMonitor.summary =
+ globalClass.resources.getString(R.string.apk_bundle_merge_success)
+ }
+ }
+
+ fun mergeBundleFile(holder: LocalFileHolder): LocalFileHolder? {
+ val inputFile = holder.file
+
+ if (inputFile.parent == null) {
+ markAsFailed(globalClass.resources.getString(R.string.invalid_bundle_file))
+ return null
+ }
+
+ val outputFile = if (parameters!!.autoSign) {
+ File.createTempFile(inputFile.nameWithoutExtension, ".apk")
+ } else {
+ File(inputFile.parentFile, inputFile.nameWithoutExtension + "-unsigned.apk")
+ }
+
+ val options = MergerOptions().apply {
+ this.inputFile = inputFile
+ this.outputFile = outputFile
+ }
+
+ try {
+ Merger(options).runCommand()
+ } catch (e: Exception) {
+ logger.logError(e)
+ markAsFailed(
+ globalClass.resources.getString(
+ R.string.task_summary_failed,
+ e.message ?: emptyString
+ )
+ )
+ return null
+ }
+
+ if (outputFile.exists() && outputFile.length() > 0) {
+ return LocalFileHolder(outputFile)
+ }
+
+ markAsFailed(globalClass.resources.getString(R.string.apk_bundle_merge_failed))
+ return null
+ }
+
+ fun signApkFile(mergedFile: LocalFileHolder) {
+ val sourceFile = sourceContent as LocalFileHolder
+
+ val inputFile = mergedFile.file
+
+ if (sourceFile.file.parent == null) {
+ markAsFailed(globalClass.resources.getString(R.string.invalid_bundle_file))
+ return
+ }
+
+ val outputFile = File(
+ sourceFile.file.parentFile,
+ sourceFile.file.nameWithoutExtension + "-signed.apk"
+ )
+
+ try {
+ val testKeyInputStream = globalClass.assets.open("keystore/testkey.pk8")
+ val certificateInputStream = globalClass.assets.open("keystore/testkey.x509.pem")
+
+ val testKey = testKeyInputStream.use { inputStream ->
+ val keyBytes = inputStream.readBytes()
+ val keySpec = PKCS8EncodedKeySpec(keyBytes)
+ val keyFactory = KeyFactory.getInstance("RSA")
+ keyFactory.generatePrivate(keySpec)
+ }
+ val certificate = certificateInputStream.use { inputStream ->
+ val certificateFactory = CertificateFactory.getInstance("X.509")
+ certificateFactory.generateCertificate(inputStream) as X509Certificate
+ }
+
+ val keyConfig = KeyConfig.Jca(testKey)
+
+ val signerConfig = ApkSigner.SignerConfig.Builder(
+ "Android",
+ keyConfig,
+ listOf(certificate)
+ ).build()
+
+ ApkSigner.Builder(listOf(signerConfig))
+ .setInputApk(inputFile)
+ .setOutputApk(outputFile)
+ .setV1SigningEnabled(true)
+ .setV2SigningEnabled(true)
+ .build()
+ .sign()
+
+ if (!outputFile.exists() || outputFile.length() == 0L) {
+ markAsFailed(globalClass.resources.getString(R.string.apk_bundle_sign_failed))
+ return
+ }
+
+ inputFile.delete()
+ } catch (e: Exception) {
+ logger.logError(e)
+ markAsFailed(
+ globalClass.resources.getString(
+ R.string.task_summary_failed,
+ e.message ?: emptyString
+ )
+ )
+ }
+ }
+
+ override suspend fun continueTask() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
+ }
+ run(parameters!!)
+ }
+
+ override fun setParameters(params: TaskParameters) {
+ parameters = params as ApksMergeTaskParameters
+ }
+}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt
index c8749c52..05c3ef39 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CompressTask.kt
@@ -1,196 +1,151 @@
package com.raival.compose.file.explorer.screen.main.tab.files.task
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Compress
-import androidx.compose.ui.graphics.vector.ImageVector
-import com.anggrayudi.storage.extension.launchOnUiThread
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.addIfAbsent
import com.raival.compose.file.explorer.common.extension.emptyString
-import com.raival.compose.file.explorer.common.extension.randomString
-import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import java.io.BufferedInputStream
-import java.io.BufferedOutputStream
-import java.io.ByteArrayOutputStream
-import java.util.zip.ZipEntry
-import java.util.zip.ZipInputStream
-import java.util.zip.ZipOutputStream
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import net.lingala.zip4j.ZipFile
+import java.io.File
class CompressTask(
- private val source: List
-) : FilesTabTask() {
- override val id: String = String.randomString(8)
-
- override fun getTitle(): String = globalClass.getString(R.string.compress)
-
- override fun getSubtitle(): String = if (source.size == 1)
- source[0].path.trimToLastTwoSegments()
- else globalClass.getString(R.string.task_subtitle, source.size)
-
- override suspend fun execute(destination: DocumentHolder, callback: Any) {
- val taskCallback = callback as FilesTabTaskCallback
-
- val total: Int
- var completed = 0
- var skipped = 0
-
- val entriesToAdded = arrayListOf()
-
- val details = FilesTabTaskDetails(
- this,
- TASK_COMPRESS,
- getTitle(),
- globalClass.getString(R.string.preparing),
- emptyString,
- 0f
+ val sourceContent: List
+) : Task() {
+ private var parameters: CompressTaskParameters? = null
+ private var pendingContent = arrayListOf()
+
+ override val metadata = System.currentTimeMillis().toFormattedDate().let { time ->
+ TaskMetadata(
+ id = id,
+ creationTime = time,
+ title = globalClass.resources.getString(R.string.compress),
+ subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size),
+ displayDetails = sourceContent.joinToString(", ") { it.displayName },
+ fullDetails = buildString {
+ sourceContent.forEachIndexed { index, source ->
+ append(source.displayName)
+ append("\n")
+ }
+ append("\n")
+ append(time)
+ },
+ isCancellable = true,
+ canMoveToBackground = true
)
+ }
+ override val progressMonitor = TaskProgressMonitor(
+ status = TaskStatus.PENDING,
+ taskTitle = metadata.title,
+ )
- taskCallback.onPrepare(details)
+ override fun getCurrentStatus() = progressMonitor.status
- val filesToCompress = arrayListOf()
+ override fun validate() = sourceContent.find { !it.isValid() } == null
- source.forEach {
- filesToCompress.add(it.path)
- if (!it.isFile) {
- filesToCompress.addAll(it.walk(true).map { f -> f.path })
- }
+ private fun markAsFailed(info: String) {
+ progressMonitor.apply {
+ status = TaskStatus.FAILED
+ summary = info
}
+ }
- total = filesToCompress.size
-
- fun updateProgress(info: String = emptyString): FilesTabTaskDetails {
- return details.apply {
- if (info.isNotEmpty()) this.info = info
- if (progress >= 0) this.progress = (completed + skipped) / total.toFloat()
- subtitle = globalClass.getString(R.string.progress, completed + skipped, total)
- }
+ override suspend fun run() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
}
+ run(parameters!!)
+ }
- fun addFileToZip(
- documentHolder: DocumentHolder,
- zipOut: ZipOutputStream,
- buffer: ByteArray,
- parentPath: String
- ) {
- if (!filesToCompress.contains(documentHolder.path)) return
-
- val entryName = if (parentPath.isEmpty()) {
- documentHolder.getName()
- } else {
- "$parentPath/${documentHolder.getName()}"
- }
-
- if (documentHolder.isFolder) {
- val children = documentHolder.listContent(false)
- if (children.isNotEmpty()) {
- children.forEach { child ->
- addFileToZip(child, zipOut, buffer, entryName)
- }
- } else {
- val entry = ZipEntry("$entryName/")
- zipOut.putNextEntry(entry)
- zipOut.closeEntry()
- completed++
- entriesToAdded.addIfAbsent(entryName)
- }
- } else {
- taskCallback.onReport(
- updateProgress(
- info = globalClass.getString(
- R.string.compressing,
- documentHolder.getName()
- )
- )
- )
-
- val inputStream =
- globalClass.contentResolver.openInputStream(documentHolder.uri)
-
- if (inputStream == null) {
- skipped++
- taskCallback.onReport(updateProgress())
- }
-
- inputStream?.use { input ->
- val entry = ZipEntry(entryName)
- zipOut.putNextEntry(entry)
-
- var length: Int
- while (input.read(buffer).also { length = it } > 0) {
- zipOut.write(buffer, 0, length)
- }
+ override suspend fun run(params: TaskParameters) {
+ parameters = params as CompressTaskParameters
+ progressMonitor.status = TaskStatus.RUNNING
+ protect = false
- zipOut.closeEntry()
+ if (sourceContent.isEmpty()) {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src))
+ return
+ }
- completed++
+ progressMonitor.processName = globalClass.resources.getString(R.string.preparing)
- entriesToAdded.addIfAbsent(entryName)
+ val basePath = sourceContent[0].getParent()?.uniquePath ?: emptyString
- taskCallback.onReport(updateProgress())
- }
+ if (pendingContent.isEmpty()) {
+ sourceContent.forEachIndexed { index, content ->
+ pendingContent.add(
+ TaskContentItem(
+ content = content,
+ relativePath = content.uniquePath.removePrefix("/$basePath"),
+ status = TaskContentStatus.PENDING
+ )
+ )
}
}
- val buffer = ByteArray(1024)
- val byteArrayOutputStream = ByteArrayOutputStream()
- destination.openInputStream().use { inputStream ->
- ZipInputStream(BufferedInputStream(inputStream)).use { zipInputStream ->
- ZipOutputStream(BufferedOutputStream(byteArrayOutputStream)).use { zipOut ->
- source.forEach { documentHolder ->
- addFileToZip(documentHolder, zipOut, buffer, emptyString)
- }
+ progressMonitor.apply {
+ totalContent = pendingContent.size
+ processName = globalClass.getString(R.string.compressing)
+ }
- var entry: ZipEntry? = zipInputStream.nextEntry
- while (entry != null) {
- if (entriesToAdded.contains(entry.name)) {
- zipOut.closeEntry()
- entry = zipInputStream.nextEntry
- continue
- }
+ ZipFile(parameters!!.destPath).use { zipFile ->
+ pendingContent.forEachIndexed { index, itemToCompress ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
- zipOut.putNextEntry(entry)
+ if (itemToCompress.status == TaskContentStatus.PENDING) {
+ progressMonitor.apply {
+ contentName = itemToCompress.content.displayName
+ remainingContent = pendingContent.size - (index + 1)
+ progress = -1f
+ }
- var len: Int
- while (zipInputStream.read(buffer).also { len = it } > 0) {
- zipOut.write(buffer, 0, len)
+ try {
+ if (itemToCompress.content.isFolder) {
+ zipFile.addFolder(File(itemToCompress.content.uniquePath))
+ } else {
+ zipFile.addFile(itemToCompress.content.uniquePath)
}
-
- zipOut.closeEntry()
- entry = zipInputStream.nextEntry
+ itemToCompress.status = TaskContentStatus.SUCCESS
+ } catch (e: Exception) {
+ logger.logError(e)
+ markAsFailed(
+ globalClass.resources.getString(
+ R.string.task_summary_failed,
+ e.message ?: emptyString
+ )
+ )
+ return
}
}
}
}
- val tempDestination = DocumentHolder.fromFile(
- kotlin.io.path.createTempFile(String.randomString(16)).toFile()
- )
- tempDestination.openOutputStream()?.use { outputStream ->
- byteArrayOutputStream.writeTo(outputStream)
- }
-
- destination.writeText(emptyString)
-
- tempDestination.openInputStream()?.use { inputStream ->
- destination.openOutputStream()?.use { outputStream ->
- inputStream.copyTo(outputStream)
+ if (progressMonitor.status == TaskStatus.RUNNING) {
+ progressMonitor.status = TaskStatus.SUCCESS
+ progressMonitor.summary = buildString {
+ pendingContent.forEach { content ->
+ append(content.content.displayName)
+ append(" -> ")
+ append(content.status.name)
+ }
}
}
+ }
- tempDestination.delete()
+ override fun setParameters(params: TaskParameters) {
+ parameters = params as CompressTaskParameters
+ }
- launchOnUiThread {
- taskCallback.onComplete(details.apply {
- subtitle = globalClass.getString(R.string.done)
- })
+ override suspend fun continueTask() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
}
+ run(parameters!!)
}
- override fun getIcon(): ImageVector = Icons.Rounded.Compress
-
- override fun getSourceFiles(): List {
- return source
- }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt
index c68378e2..1cf2fc88 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/CopyTask.kt
@@ -1,148 +1,731 @@
package com.raival.compose.file.explorer.screen.main.tab.files.task
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.ContentCopy
-import androidx.compose.ui.graphics.vector.ImageVector
-import com.anggrayudi.storage.extension.launchOnUiThread
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.emptyString
-import com.raival.compose.file.explorer.common.extension.isNot
-import com.raival.compose.file.explorer.common.extension.randomString
-import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.common.extension.listFilesAndEmptyDirs
+import com.raival.compose.file.explorer.common.extension.orIf
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.common.extension.toRelativeString
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder
+import net.lingala.zip4j.ZipFile
+import net.lingala.zip4j.model.ZipParameters
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
class CopyTask(
- private val source: List
-) : FilesTabTask() {
- override val id: String = String.randomString(8)
-
- override fun getTitle(): String = globalClass.getString(R.string.copy)
-
- override fun getSubtitle(): String = if (source.size == 1)
- source[0].path.trimToLastTwoSegments()
- else globalClass.getString(R.string.task_subtitle, source.size)
-
- override suspend fun execute(destination: DocumentHolder, callback: Any) {
- val taskCallback = callback as FilesTabTaskCallback
-
- val total: Int
- var completed = 0
- var replaced = 0
- var skipped = 0
-
- val details = FilesTabTaskDetails(
- this,
- TASK_COPY,
- getTitle(),
- globalClass.getString(R.string.preparing),
- emptyString,
- 0f
+ val sourceFiles: List,
+ val deleteSourceFiles: Boolean
+) : Task() {
+ private var parameters: CopyTaskParameters? = null
+ private val pendingFiles: ArrayList = arrayListOf()
+
+ override val metadata = createTaskMetadata()
+ override val progressMonitor = TaskProgressMonitor(
+ status = TaskStatus.PENDING,
+ taskTitle = metadata.title,
+ )
+
+ override fun getCurrentStatus() = progressMonitor.status
+ override fun validate() = sourceFiles.all { it.isValid() && it.canRead }
+
+ override suspend fun run() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
+ }
+ run(parameters!!)
+ }
+
+ override suspend fun run(params: TaskParameters) {
+ parameters = params as? CopyTaskParameters
+
+ if (!initializeTask()) return
+
+ try {
+ executeTaskBasedOnSourceType()
+ if (deleteSourceFiles && progressMonitor.status == TaskStatus.RUNNING) {
+ performSourceDeletion()
+ }
+ finalizeTask()
+ } catch (e: Exception) {
+ handleTaskError(e)
+ }
+ }
+
+ override suspend fun continueTask() {
+ parameters?.let { run(it) } ?: run {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_missing_destination))
+ }
+ }
+
+ override fun setParameters(params: TaskParameters) {
+ parameters = params as? CopyTaskParameters
+ }
+
+ // Private helper methods
+
+ private fun createTaskMetadata(): TaskMetadata {
+ val time = System.currentTimeMillis().toFormattedDate()
+ return TaskMetadata(
+ id = id,
+ creationTime = time,
+ title = globalClass.resources.getString(
+ if (deleteSourceFiles) R.string.move else R.string.copy
+ ),
+ subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceFiles.size),
+ displayDetails = sourceFiles.joinToString(", ") { it.displayName },
+ fullDetails = buildString {
+ sourceFiles.forEach { source ->
+ appendLine(source.displayName)
+ }
+ appendLine()
+ append(time)
+ },
+ isCancellable = true,
+ canMoveToBackground = true
)
+ }
- taskCallback.onPrepare(details)
+ private fun initializeTask(): Boolean {
+ progressMonitor.status = TaskStatus.RUNNING
+ aborted = false
+ protect = false
- val filesToCopy = arrayListOf()
+ if (sourceFiles.isEmpty()) {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src))
+ return false
+ }
- source.forEach {
- filesToCopy.add(it.path)
- if (!it.isFile) {
- filesToCopy.addAll(it.walk(true).map { f -> f.path })
+ parameters?.let { params ->
+ if (!validateDestination(params.destHolder)) {
+ return false
}
+ } ?: run {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_missing_destination))
+ return false
}
- total = filesToCopy.size
+ return true
+ }
+
+ private fun validateDestination(destHolder: ContentHolder): Boolean {
+ val firstSource = sourceFiles.first()
- fun updateProgress(info: String): FilesTabTaskDetails {
- return details.apply {
- globalClass.getString(R.string.progress, completed + skipped + replaced, total)
- if (progress >= 0) this.progress =
- (completed + replaced + skipped) / total.toFloat()
- if (info.isNotEmpty()) this.info = info
+ // Prevent copying into self for local files
+ if (firstSource is LocalFileHolder && destHolder is LocalFileHolder) {
+ val sourceParent = firstSource.file.parentFile?.canonicalPath
+ val destPath = destHolder.file.canonicalPath
+ if (sourceParent == destPath) {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_invalid_dest))
+ return false
}
}
- fun copyFile(from: DocumentHolder, to: DocumentHolder) {
- if (!filesToCopy.contains(from.path)) return
+ // Prevent copying zip directory into itself
+ if (firstSource is ZipFileHolder && destHolder is ZipFileHolder) {
+ val sourceFile = firstSource.zipTree.source.file.canonicalPath
+ val destFile = destHolder.zipTree.source.file.canonicalPath
- val existingFile = from.getName().let { to.findFile(it) }
+ if (sourceFile == destFile) {
+ val destPath = destHolder.node.path
+ val hasInvalidNesting = sourceFiles
+ .filterIsInstance()
+ .filter { !it.isFile() }
+ .any { destPath.startsWith(it.node.path) }
- taskCallback.onReport(
- updateProgress(
- info = globalClass.getString(
- R.string.copying_destination,
- from.getName(),
- destination.getName()
- )
- )
+ if (hasInvalidNesting) {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_invalid_dest))
+ return false
+ }
+ }
+ }
+
+ return true
+ }
+
+ private fun executeTaskBasedOnSourceType() {
+ val sample = sourceFiles.first()
+ val destHolder = parameters!!.destHolder
+ val sourcePath = sample.getParent()?.uniquePath ?: emptyString
+
+ when {
+ sample is LocalFileHolder && destHolder is LocalFileHolder ->
+ copyLocalFiles(sourcePath, destHolder)
+
+ sample is LocalFileHolder && destHolder is ZipFileHolder ->
+ copyLocalFilesToZip(sourcePath, destHolder)
+
+ sample is ZipFileHolder && destHolder is LocalFileHolder ->
+ copyZipFilesToLocal(sourcePath, destHolder)
+
+ sample is ZipFileHolder && destHolder is ZipFileHolder ->
+ copyZipFilesToZip(sourcePath, destHolder)
+
+ else ->
+ throw IllegalStateException(globalClass.getString(R.string.unsupported_source_destination_combination))
+ }
+ }
+
+ private fun markAsFailed(info: String) {
+ progressMonitor.apply {
+ status = TaskStatus.FAILED
+ summary = info
+ }
+ }
+
+ private fun handleTaskError(e: Exception) {
+ logger.logError(e)
+ markAsFailed(
+ globalClass.resources.getString(
+ R.string.task_summary_failed,
+ e.message ?: globalClass.getString(R.string.unknown_error)
)
+ )
+ }
- if (existingFile isNot null) {
- if (from.path == existingFile?.path || !existingFile!!.delete()) {
- skipped++
- return
+ private fun finalizeTask() {
+ if (progressMonitor.status == TaskStatus.RUNNING) {
+ progressMonitor.status = TaskStatus.SUCCESS
+ progressMonitor.summary = buildString {
+ pendingFiles.forEach { content ->
+ append(content.content.displayName)
+ append(" -> ")
+ appendLine(content.status.name)
}
}
+ }
+ }
- from.getName().let { fileName ->
- to.createSubFile(fileName)?.let { target ->
- val inStream = from.openInputStream()
- val outStream = target.openOutputStream()
+ private fun preparePendingFiles(sourcePath: String) {
+ if (pendingFiles.isEmpty()) {
+ sourceFiles.forEach { source ->
+ pendingFiles.addAll(listFilesWithRelativePath(sourcePath, source))
+ }
+ }
+ }
- inStream.use { input ->
- outStream.use { output ->
- input?.copyTo(output ?: return)
- }
+ private fun updateProgress(index: Int, itemName: String) {
+ progressMonitor.apply {
+ contentName = itemName
+ remainingContent = pendingFiles.size - (index + 1)
+ progress = (index + 1f) / pendingFiles.size
+ }
+ }
+
+ private fun handleConflict(item: TaskContentItem, existsAsFile: Boolean): Boolean {
+ if (!existsAsFile) return true // No conflict for directories or non-existent files
+
+ return when (commonConflictResolution) {
+ TaskContentStatus.SKIP -> {
+ item.status = TaskContentStatus.SKIP
+ true
+ }
+
+ TaskContentStatus.ASK -> {
+ item.status = TaskContentStatus.CONFLICT
+ progressMonitor.status = TaskStatus.CONFLICT
+ globalClass.taskManager.taskInterceptor.interceptTask(item, this)
+ false // Pause execution
+ }
+
+ TaskContentStatus.REPLACE -> {
+ item.status = TaskContentStatus.REPLACE
+ true
+ }
+
+ else -> true
+ }
+ }
+
+ private fun performSourceDeletion() {
+ if (!deleteSourceFiles) return
+
+ progressMonitor.processName =
+ globalClass.resources.getString(R.string.deleting_source_files)
+
+ when (val sample = sourceFiles.first()) {
+ is LocalFileHolder -> deleteLocalSources()
+ is ZipFileHolder -> deleteZipSources(sample)
+ }
+ }
+
+ private fun deleteLocalSources() {
+ val successfulItems = pendingFiles.filter { it.status == TaskContentStatus.SUCCESS }
+
+ successfulItems.forEachIndexed { index, item ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
+
+ progressMonitor.apply {
+ remainingContent = successfulItems.size - (index + 1)
+ progress = (index + 1f) / successfulItems.size
+ }
+
+ (item.content as LocalFileHolder).file.deleteRecursively()
+ }
+
+ // Clean up empty directories
+ sourceFiles.forEach { content ->
+ val file = (content as LocalFileHolder).file
+ if (file.exists() && file.walkTopDown().none { it.isFile }) {
+ file.deleteRecursively()
+ }
+ }
+ }
+
+ private fun deleteZipSources(sample: ZipFileHolder) {
+ try {
+ ZipFile(sample.zipTree.source.file).use { zipFile ->
+ val successfulPaths = pendingFiles
+ .filter { it.status == TaskContentStatus.SUCCESS }
+ .map { (it.content as ZipFileHolder).node.path }
+
+ if (successfulPaths.isNotEmpty()) {
+ zipFile.removeFiles(successfulPaths)
+ }
+
+ // Remove empty directories
+ sourceFiles.forEach { src ->
+ val zipSrc = src as ZipFileHolder
+ val hasFiles = zipSrc.node.listFilesAndEmptyDirs().any { !it.isDirectory }
+ if (!hasFiles) {
+ zipFile.removeFile(zipSrc.node.path)
}
+ }
+ }
+ } catch (e: Exception) {
+ logger.logError(e)
+ // Don't fail the entire task if deletion fails
+ }
+ }
+
+ // Specialized copy methods
- if (existingFile isNot null) replaced++ else completed++
+ private fun copyLocalFiles(sourcePath: String, destinationHolder: LocalFileHolder) {
+ progressMonitor.processName = globalClass.resources.getString(R.string.counting_files)
+ preparePendingFiles(sourcePath)
- taskCallback.onReport(updateProgress(emptyString))
+ progressMonitor.apply {
+ totalContent = pendingFiles.size
+ processName = globalClass.resources.getString(R.string.copying)
+ }
+
+ pendingFiles.forEachIndexed { index, item ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
+ if (item.status != TaskContentStatus.PENDING
+ && item.status != TaskContentStatus.REPLACE
+ && item.status != TaskContentStatus.CONFLICT
+ ) {
+ return@forEachIndexed
+ }
+
+ val sourceFile = (item.content as LocalFileHolder).file
+ val destinationFile = File(destinationHolder.file, item.relativePath)
+
+ updateProgress(index, sourceFile.name)
+
+ if (item.status == TaskContentStatus.CONFLICT && !handleConflict(item, true)) {
+ return
+ }
+
+ if (item.status == TaskContentStatus.PENDING) {
+ val conflictExists = destinationFile.exists() && destinationFile.isFile
+ if (conflictExists && !handleConflict(item, true)) {
return
}
}
- skipped++
+ when (item.status) {
+ TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> {
+ item.status = if (copyLocalFile(
+ sourceFile,
+ destinationFile,
+ item.status == TaskContentStatus.REPLACE
+ )
+ ) {
+ TaskContentStatus.SUCCESS
+ } else {
+ TaskContentStatus.FAILED
+ }
+ }
+
+ else -> { /* Already handled */
+ }
+ }
}
+ }
- fun copyFolder(from: DocumentHolder, to: DocumentHolder) {
- if (!filesToCopy.contains(from.path)) return
+ private fun copyLocalFile(source: File, destination: File, overwrite: Boolean): Boolean {
+ return try {
+ if (source.isFile) {
+ destination.parentFile?.mkdirs()
+ if (deleteSourceFiles) {
+ Files.move(
+ source.toPath(),
+ destination.toPath(),
+ *buildList {
+ add(StandardCopyOption.ATOMIC_MOVE)
+ if (overwrite) add(StandardCopyOption.REPLACE_EXISTING)
+ }.toTypedArray()
+ )
+ } else {
+ source.copyTo(destination, overwrite)
+ }
+ } else {
+ destination.mkdirs() || destination.exists()
+ }
+ true
+ } catch (e: Exception) {
+ logger.logError(e)
+ false
+ }
+ }
- val newFolder = from.getName().let { to.createSubFolder(it) }
+ private fun copyLocalFilesToZip(sourcePath: String, destinationHolder: ZipFileHolder) {
+ progressMonitor.processName = globalClass.resources.getString(R.string.counting_files)
+ preparePendingFiles(sourcePath)
- if (newFolder isNot null) {
- completed++
- from.listContent(false).forEach { currentFile ->
- if (currentFile.isFile) {
- copyFile(currentFile, newFolder!!)
- } else {
- copyFolder(currentFile, newFolder!!)
+ progressMonitor.apply {
+ totalContent = pendingFiles.size
+ processName = globalClass.resources.getString(R.string.copying)
+ }
+
+ try {
+ ZipFile(destinationHolder.zipTree.source.file).use { targetZipFile ->
+ pendingFiles.forEachIndexed { index, item ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
+
+ if (item.status != TaskContentStatus.PENDING
+ && item.status != TaskContentStatus.REPLACE
+ && item.status != TaskContentStatus.CONFLICT
+ ) {
+ return@forEachIndexed
+ }
+
+ updateProgress(index, item.content.displayName)
+
+ if (item.status == TaskContentStatus.CONFLICT && !handleConflict(item, true)) {
+ return
+ }
+
+ val targetPath =
+ createZipEntryPath(destinationHolder.node.path, item.relativePath)
+
+ if (item.status == TaskContentStatus.PENDING) {
+ val existingHeader = targetZipFile.getFileHeader(targetPath)
+ val conflictExists = existingHeader != null && !existingHeader.isDirectory
+ if (conflictExists && !handleConflict(item, true)) {
+ return
+ }
+ }
+
+ when (item.status) {
+ TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> {
+ item.status = if (addLocalFileToZip(
+ targetZipFile,
+ item,
+ targetPath,
+ item.status == TaskContentStatus.REPLACE
+ )
+ ) {
+ TaskContentStatus.SUCCESS
+ } else {
+ TaskContentStatus.FAILED
+ }
+ }
+
+ else -> { /* Already handled */
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ throw RuntimeException(globalClass.getString(R.string.failed_to_copy_files_to_zip), e)
+ }
+ }
+
+ private fun addLocalFileToZip(
+ zipFile: ZipFile,
+ item: TaskContentItem,
+ targetPath: String,
+ overwrite: Boolean
+ ): Boolean {
+ return try {
+ val sourceFile = (item.content as LocalFileHolder).file
+ val params = ZipParameters().apply {
+ isOverrideExistingFilesInZip = overwrite
+ fileNameInZip = targetPath
+ }
+
+ if (sourceFile.isFile) {
+ zipFile.addFile(sourceFile, params)
+ } else {
+ zipFile.addFolder(sourceFile, params)
+ }
+ true
+ } catch (e: Exception) {
+ logger.logError(e)
+ false
+ }
+ }
+
+ private fun copyZipFilesToLocal(sourcePath: String, destinationHolder: LocalFileHolder) {
+ progressMonitor.processName = globalClass.resources.getString(R.string.counting_files)
+ preparePendingFiles(sourcePath)
+
+ progressMonitor.apply {
+ totalContent = pendingFiles.size
+ processName = globalClass.resources.getString(R.string.extracting)
+ }
+
+ val sourceZipFile = (sourceFiles.first() as ZipFileHolder).zipTree.source.file
+ try {
+ ZipFile(sourceZipFile).use { sourceZip ->
+ pendingFiles.forEachIndexed { index, item ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
+
+ if (item.status != TaskContentStatus.PENDING
+ && item.status != TaskContentStatus.REPLACE
+ && item.status != TaskContentStatus.CONFLICT
+ ) {
+ return@forEachIndexed
+ }
+
+ val sourceHolder = item.content as ZipFileHolder
+ val destinationFile = File(destinationHolder.uniquePath, item.relativePath)
+
+ updateProgress(index, sourceHolder.displayName)
+
+ if (item.status == TaskContentStatus.CONFLICT && !handleConflict(item, true)) {
+ return
+ }
+
+ if (item.status == TaskContentStatus.PENDING) {
+ val conflictExists = destinationFile.exists() && destinationFile.isFile
+ if (conflictExists && !handleConflict(item, true)) {
+ return
+ }
+ }
+
+ when (item.status) {
+ TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> {
+ item.status =
+ if (extractZipEntry(sourceZip, sourceHolder, destinationFile)) {
+ TaskContentStatus.SUCCESS
+ } else {
+ TaskContentStatus.FAILED
+ }
+ }
+
+ else -> { /* Already handled */
+ }
}
}
+ }
+ } catch (e: Exception) {
+ throw RuntimeException(
+ globalClass.getString(R.string.failed_to_extract_files_from_zip),
+ e
+ )
+ }
+ }
+
+ private fun extractZipEntry(
+ sourceZip: ZipFile,
+ sourceHolder: ZipFileHolder,
+ destinationFile: File
+ ): Boolean {
+ return try {
+ if (sourceHolder.isFile()) {
+ destinationFile.parentFile?.mkdirs()
+ sourceZip.getInputStream(sourceZip.getFileHeader(sourceHolder.node.path))
+ .use { input ->
+ destinationFile.outputStream().use { output ->
+ input.copyTo(output)
+ }
+ }
} else {
- skipped++
+ destinationFile.mkdirs()
}
+ true
+ } catch (e: Exception) {
+ logger.logError(e)
+ false
+ }
+ }
+
+ private fun copyZipFilesToZip(sourcePath: String, destinationHolder: ZipFileHolder) {
+ progressMonitor.processName = globalClass.resources.getString(R.string.counting_files)
+ preparePendingFiles(sourcePath)
+
+ progressMonitor.apply {
+ totalContent = pendingFiles.size
+ processName = globalClass.resources.getString(R.string.copying)
}
- source.forEach { currentFile ->
- if (currentFile.isFile) {
- copyFile(currentFile, destination)
+ val sourceFile = (sourceFiles.first() as ZipFileHolder).zipTree.source.file
+ val destFile = destinationHolder.zipTree.source.file
+
+ try {
+ ZipFile(sourceFile).use { sourceZip ->
+ ZipFile(destFile).use { destZip ->
+ pendingFiles.forEachIndexed { index, item ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
+
+ if (item.status != TaskContentStatus.PENDING
+ && item.status != TaskContentStatus.REPLACE
+ && item.status != TaskContentStatus.CONFLICT
+ ) {
+ return@forEachIndexed
+ }
+
+ updateProgress(index, item.content.displayName)
+
+ if (item.status == TaskContentStatus.CONFLICT && !handleConflict(
+ item,
+ true
+ )
+ ) {
+ return
+ }
+
+ val targetPath =
+ createZipEntryPath(destinationHolder.node.path, item.relativePath)
+
+ if (item.status == TaskContentStatus.PENDING) {
+ val existingHeader = destZip.getFileHeader(targetPath)
+ val conflictExists =
+ existingHeader != null && !existingHeader.isDirectory
+ if (conflictExists && !handleConflict(item, true)) {
+ return
+ }
+ }
+
+ // Remove existing file if replacing
+ if (item.status == TaskContentStatus.REPLACE) {
+ try {
+ destZip.removeFile(targetPath)
+ } catch (e: Exception) {
+ logger.logError(e) // Log but continue
+ }
+ }
+
+ when (item.status) {
+ TaskContentStatus.PENDING, TaskContentStatus.REPLACE -> {
+ item.status =
+ if (copyZipEntry(sourceZip, destZip, item, targetPath)) {
+ TaskContentStatus.SUCCESS
+ } else {
+ TaskContentStatus.FAILED
+ }
+ }
+
+ else -> { /* Already handled */
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ throw RuntimeException(globalClass.getString(R.string.failed_to_copy_zip_entries), e)
+ }
+ }
+
+ private fun copyZipEntry(
+ sourceZip: ZipFile,
+ destZip: ZipFile,
+ item: TaskContentItem,
+ targetPath: String
+ ): Boolean {
+ return try {
+ val sourceHolder = item.content as ZipFileHolder
+ val params = ZipParameters().apply {
+ fileNameInZip = if (sourceHolder.isFile()) targetPath else "$targetPath/"
+ isOverrideExistingFilesInZip = true
+ }
+
+ if (sourceHolder.isFile()) {
+ sourceZip.getInputStream(sourceZip.getFileHeader(sourceHolder.node.path))
+ .use { input ->
+ destZip.addStream(input, params)
+ }
} else {
- copyFolder(currentFile, destination)
+ destZip.addStream(ByteArrayInputStream(ByteArray(0)), params)
}
+ true
+ } catch (e: Exception) {
+ logger.logError(e)
+ false
}
+ }
- launchOnUiThread {
- taskCallback.onComplete(details.apply {
- subtitle = globalClass.getString(R.string.done)
- })
+ private fun createZipEntryPath(basePath: String, relativePath: String): String {
+ return if (basePath.isEmpty()) {
+ relativePath
+ } else {
+ "$basePath${File.separator}$relativePath"
}
}
- override fun getIcon(): ImageVector = Icons.Rounded.ContentCopy
+ private fun listFilesWithRelativePath(
+ basePath: String,
+ startFile: ContentHolder
+ ): List {
+ if (startFile.isFile()) {
+ return listOf(
+ TaskContentItem(
+ content = startFile,
+ relativePath = startFile.displayName,
+ status = TaskContentStatus.PENDING
+ )
+ )
+ }
+
+ return when (startFile) {
+ is LocalFileHolder -> {
+ startFile.file.listFilesAndEmptyDirs().map { file ->
+ TaskContentItem(
+ content = LocalFileHolder(file),
+ relativePath = file.toRelativeString(File(basePath))
+ .orIf(startFile.displayName) { it.isEmpty() },
+ status = TaskContentStatus.PENDING
+ )
+ }
+ }
+
+ is ZipFileHolder -> {
+ startFile.node.listFilesAndEmptyDirs().map { node ->
+ TaskContentItem(
+ content = ZipFileHolder(startFile.zipTree, node),
+ relativePath = node.path.toRelativeString(basePath)
+ .orIf(startFile.displayName) { it.isEmpty() },
+ status = TaskContentStatus.PENDING
+ )
+ }
+ }
- override fun getSourceFiles(): List {
- return source
+ else -> emptyList()
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DecompressTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DecompressTask.kt
deleted file mode 100644
index 25aabebe..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DecompressTask.kt
+++ /dev/null
@@ -1,144 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.task
-
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Compress
-import androidx.compose.ui.graphics.vector.ImageVector
-import com.anggrayudi.storage.extension.launchOnUiThread
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.emptyString
-import com.raival.compose.file.explorer.common.extension.isNot
-import com.raival.compose.file.explorer.common.extension.randomString
-import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import java.io.InputStream
-import java.io.OutputStream
-import java.util.zip.ZipEntry
-import java.util.zip.ZipInputStream
-
-class DecompressTask(
- private val source: List
-) : FilesTabTask() {
- override val id: String = String.randomString(8)
-
- override fun getTitle(): String = globalClass.getString(R.string.decompress)
-
- override fun getSubtitle(): String = if (source.size == 1)
- source[0].path.trimToLastTwoSegments()
- else globalClass.getString(R.string.task_subtitle, source.size)
-
- override suspend fun execute(destination: DocumentHolder, callback: Any) {
- val taskCallback = callback as FilesTabTaskCallback
-
- var total = 0
- var completed = 0
- var skipped = 0
-
- val details = FilesTabTaskDetails(
- this,
- TASK_DECOMPRESS,
- getTitle(),
- globalClass.getString(R.string.preparing),
- emptyString,
- 0f
- )
-
- taskCallback.onPrepare(details)
-
- globalClass.contentResolver.openInputStream(source[0].uri)?.use { zipInputStream ->
- ZipInputStream(zipInputStream).use { zis ->
- var zipEntry: ZipEntry?
- while (zis.nextEntry.also { zipEntry = it } != null) {
- zipEntry?.let { entry ->
- if (!entry.isDirectory) total++
- }
- }
- }
- }
-
- fun updateProgress(info: String = emptyString): FilesTabTaskDetails {
- return details.apply {
- if (info.isNotEmpty()) this.info = info
- if (progress >= 0) this.progress = (completed + skipped.toFloat()) / total
- subtitle = globalClass.getString(R.string.progress, completed + skipped, total)
- }
- }
-
- taskCallback.onReport(updateProgress())
-
- fun createFileInTargetDir(targetDir: DocumentHolder, entry: ZipEntry): DocumentHolder? {
- var currentDir = targetDir
- val pathSegments = entry.name.split("/")
- for (i in 0 until pathSegments.size - 1) {
- currentDir = currentDir.findFile(pathSegments[i])
- ?: currentDir.createSubFolder(pathSegments[i]) ?: return null
- }
-
- val existingFile = currentDir.findFile(pathSegments.last())
-
- if (existingFile isNot null) {
- if (existingFile!!.isFolder) existingFile.deleteRecursively()
- else existingFile.delete()
- }
-
- return if (entry.isDirectory) {
- currentDir.createSubFolder(pathSegments.last())
- } else {
- currentDir.createSubFile(pathSegments.last())
- }
- }
-
- fun copyStream(input: InputStream, output: OutputStream) {
- val buffer = ByteArray(1024)
- var length: Int
- while (input.read(buffer).also { length = it } > 0) {
- output.write(buffer, 0, length)
- }
- }
-
- fun writeToFile(zis: ZipInputStream, outputFile: DocumentHolder?) {
- outputFile?.uri?.let { uri ->
- globalClass.contentResolver.openOutputStream(uri)?.use { outputStream ->
- copyStream(zis, outputStream)
- }
- }
- }
-
- val inputStream = globalClass.contentResolver.openInputStream(source[0].uri)
-
- inputStream?.use { zipInputStream ->
- ZipInputStream(zipInputStream).use { zis ->
- var zipEntry: ZipEntry?
- while (zis.nextEntry.also { zipEntry = it } != null) {
- zipEntry?.let { entry ->
- taskCallback.onReport(
- updateProgress(
- info = globalClass.getString(R.string.decompressing, entry.name)
- )
- )
- val outputFile = createFileInTargetDir(destination, entry)
- if (outputFile isNot null && !entry.isDirectory) {
- writeToFile(zis, outputFile)
- completed++
- } else if (outputFile == null) {
- skipped++
- }
- taskCallback.onReport(updateProgress())
- }
- }
- }
- }
-
- launchOnUiThread {
- taskCallback.onComplete(details.apply {
- subtitle = globalClass.getString(R.string.done)
- })
- }
- }
-
- override fun getIcon(): ImageVector = Icons.Rounded.Compress
-
- override fun getSourceFiles(): List {
- return source
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt
index 82b75f91..47d1bd96 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/DeleteTask.kt
@@ -1,142 +1,160 @@
package com.raival.compose.file.explorer.screen.main.tab.files.task
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.ContentCut
-import androidx.compose.ui.graphics.vector.ImageVector
-import com.anggrayudi.storage.extension.launchOnUiThread
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.emptyString
-import com.raival.compose.file.explorer.common.extension.randomString
-import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import java.io.File
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder
+import net.lingala.zip4j.ZipFile
class DeleteTask(
- private val source: List,
- private val moveToRecycleBin: Boolean = false
-) : FilesTabTask() {
- override val id: String = String.randomString(8)
-
- override fun getTitle(): String = globalClass.getString(R.string.delete)
-
- override fun getSubtitle(): String = if (source.size == 1)
- source[0].path.trimToLastTwoSegments()
- else globalClass.getString(R.string.task_subtitle, source.size)
-
- override suspend fun execute(destination: DocumentHolder, callback: Any) {
- if (moveToRecycleBin && !destination.hasParent(globalClass.recycleBinDir)) {
- MoveTask(source).execute(
- DocumentHolder.fromFile(
- File(
- globalClass.recycleBinDir.toFile()!!,
- "${System.currentTimeMillis()}"
- ).apply {
- mkdirs()
- }
- ),
- callback
- )
- return
+ val sourceContent: List
+) : Task() {
+ private var parameters: DeleteTaskParameters? = null
+ private var pendingContent = arrayListOf()
+
+ override val metadata = System.currentTimeMillis().toFormattedDate().let { time ->
+ TaskMetadata(
+ id = id,
+ creationTime = time,
+ title = globalClass.resources.getString(R.string.delete),
+ subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size),
+ displayDetails = sourceContent.joinToString(", ") { it.displayName },
+ fullDetails = buildString {
+ sourceContent.forEachIndexed { index, source ->
+ append(source.displayName)
+ append("\n")
+ }
+ append("\n")
+ append(time)
+ },
+ isCancellable = true,
+ canMoveToBackground = true
+ )
+ }
+
+ override val progressMonitor = TaskProgressMonitor(
+ status = TaskStatus.PENDING,
+ taskTitle = metadata.title,
+ )
+
+ override fun getCurrentStatus() = progressMonitor.status
+
+ override fun validate() = sourceContent.find { !it.isValid() } == null
+
+ private fun markAsFailed(info: String) {
+ progressMonitor.apply {
+ status = TaskStatus.FAILED
+ summary = info
}
+ }
- val taskCallback = callback as FilesTabTaskCallback
+ override suspend fun run() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
+ }
+ run(parameters!!)
+ }
- var deleted = 0
- var skipped = 0
+ override suspend fun run(params: TaskParameters) {
+ parameters = params as DeleteTaskParameters
+ progressMonitor.status = TaskStatus.RUNNING
+ protect = false
- val details = FilesTabTaskDetails(
- this,
- TASK_DELETE,
- getTitle(),
- globalClass.getString(R.string.preparing),
- emptyString,
- -1f
- )
+ if (sourceContent.isEmpty()) {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src))
+ return
+ }
- taskCallback.onPrepare(details)
+ progressMonitor.processName = globalClass.resources.getString(R.string.preparing)
- fun updateProgress(info: String = emptyString): FilesTabTaskDetails {
- return details.apply {
- subtitle = globalClass.getString(R.string.progress_short, deleted)
- if (info.isNotEmpty()) this.info = info
+ if (pendingContent.isEmpty()) {
+ sourceContent.forEachIndexed { index, content ->
+ pendingContent.add(
+ DeleteContentItem(source = content, status = TaskContentStatus.PENDING)
+ )
}
}
- fun deleteFolder(toDelete: DocumentHolder) {
- taskCallback.onReport(
- updateProgress(
- globalClass.getString(
- R.string.deleting,
- toDelete.getName()
- )
- )
- )
+ progressMonitor.apply {
+ totalContent = pendingContent.size
+ processName = globalClass.getString(R.string.deleting)
+ }
+
+ pendingContent.forEachIndexed { index, itemToDelete ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
- if (toDelete.isEmpty()) {
- if (toDelete.delete()) {
- deleted++
- } else {
- skipped++
+ if (itemToDelete.status == TaskContentStatus.PENDING) {
+ progressMonitor.apply {
+ contentName = itemToDelete.source.displayName
+ remainingContent = pendingContent.size - (index + 1)
+ progress = (index + 1f) / pendingContent.size
}
- } else {
- toDelete.listContent(false).forEach {
- if (it.isFile) {
- taskCallback.onReport(
- updateProgress(
- globalClass.getString(
- R.string.deleting,
- it.getName()
- )
- )
- )
- if (it.delete()) {
- deleted++
- } else {
- skipped++
+
+ try {
+ when (itemToDelete.source) {
+ is LocalFileHolder -> {
+ itemToDelete.source.file.deleteRecursively()
+ }
+
+ is ZipFileHolder -> {
+ ZipFile(itemToDelete.source.zipTree.source.file).use {
+ it.removeFile(itemToDelete.source.uniquePath)
+ }
+ }
+
+ else -> {
+ itemToDelete.status = TaskContentStatus.SKIP
+ return@forEachIndexed
}
- } else {
- deleteFolder(it)
}
- }
- if (toDelete.delete()) {
- deleted++
- } else {
- skipped++
+ itemToDelete.status = TaskContentStatus.SUCCESS
+ } catch (e: Exception) {
+ logger.logError(e)
+ markAsFailed(
+ globalClass.resources.getString(
+ R.string.task_summary_failed,
+ e.message ?: emptyString
+ )
+ )
+ return
}
}
}
- source.forEach { currentFile ->
- if (currentFile.isFile) {
- taskCallback.onReport(
- updateProgress(
- globalClass.getString(
- R.string.deleting,
- currentFile.getName()
- )
- )
- )
- if (currentFile.delete()) {
- deleted++
- } else {
- skipped++
+ if (progressMonitor.status == TaskStatus.RUNNING) {
+ progressMonitor.status = TaskStatus.SUCCESS
+ progressMonitor.summary = buildString {
+ pendingContent.forEach { content ->
+ append(content.source.displayName)
+ append(" -> ")
+ append(content.status.name)
}
- } else {
- deleteFolder(currentFile)
}
}
+ }
- launchOnUiThread {
- taskCallback.onComplete(details.apply {
- subtitle = globalClass.getString(R.string.done)
- })
+ override suspend fun continueTask() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
}
+ run(parameters!!)
}
- override fun getIcon(): ImageVector = Icons.Rounded.ContentCut
-
- override fun getSourceFiles(): List {
- return source
+ override fun setParameters(params: TaskParameters) {
+ parameters = params as DeleteTaskParameters
}
+
+ internal data class DeleteContentItem(
+ val source: ContentHolder,
+ var status: TaskContentStatus
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTask.kt
deleted file mode 100644
index 579dfe5c..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTask.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.task
-
-import androidx.compose.ui.graphics.vector.ImageVector
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-
-abstract class FilesTabTask {
- abstract val id: String
- abstract fun getTitle(): String
- abstract fun getSubtitle(): String
- abstract suspend fun execute(destination: DocumentHolder, callback: Any)
- abstract fun getIcon(): ImageVector
- abstract fun getSourceFiles(): List
-
- fun isValidSourceFiles(): Boolean {
- getSourceFiles().forEach {
- if (!it.exists()) return false
- }
- return true
- }
-
- companion object {
- const val TASK_NONE = -1
- const val TASK_COPY = 0
- const val TASK_MOVE = 1
- const val TASK_DELETE = 2
- const val TASK_COMPRESS = 3
- const val TASK_DECOMPRESS = 4
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskCallback.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskCallback.kt
deleted file mode 100644
index 6c6db9a3..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskCallback.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.task
-
-import kotlinx.coroutines.CoroutineScope
-
-abstract class FilesTabTaskCallback(val processingThread: CoroutineScope) {
- abstract fun onPrepare(details: FilesTabTaskDetails)
-
- abstract fun onReport(details: FilesTabTaskDetails)
-
- abstract fun onComplete(details: FilesTabTaskDetails)
-
- abstract fun onFailed(details: FilesTabTaskDetails)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskDetails.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskDetails.kt
deleted file mode 100644
index 57d3a065..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/FilesTabTaskDetails.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.task
-
-import com.raival.compose.file.explorer.common.extension.emptyString
-
-class FilesTabTaskDetails(
- var task: FilesTabTask,
- var type: Int = FilesTabTask.TASK_NONE,
- var title: String = emptyString,
- var subtitle: String = emptyString,
- var info: String = emptyString,
- var progress: Float = -1f
-)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/MoveTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/MoveTask.kt
deleted file mode 100644
index 818a6e5e..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/MoveTask.kt
+++ /dev/null
@@ -1,200 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.task
-
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.ContentCut
-import androidx.compose.ui.graphics.vector.ImageVector
-import com.anggrayudi.storage.extension.launchOnUiThread
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.emptyString
-import com.raival.compose.file.explorer.common.extension.isNot
-import com.raival.compose.file.explorer.common.extension.randomString
-import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.Action
-import java.io.File
-
-class MoveTask(
- private val source: List
-) : FilesTabTask() {
- override val id: String = String.randomString(8)
-
- override fun getTitle(): String = globalClass.getString(R.string.move)
-
- override fun getSubtitle(): String = if (source.size == 1)
- source[0].path.trimToLastTwoSegments()
- else globalClass.getString(R.string.task_subtitle, source.size)
-
- val postActions = arrayListOf()
-
- override suspend fun execute(destination: DocumentHolder, callback: Any) {
- val taskCallback = callback as FilesTabTaskCallback
-
- val total: Int
- var completed = 0
- var replaced = 0
- var skipped = 0
-
- val details = FilesTabTaskDetails(
- this,
- TASK_MOVE,
- getTitle(),
- globalClass.getString(R.string.preparing),
- emptyString,
- 0f
- )
-
- taskCallback.onPrepare(details)
-
- val filesToMove = arrayListOf()
- val filesSkipped = arrayListOf()
-
- source.forEach {
- if (it.storageId == destination.storageId) {
- val tempFile = File(it.path)
- val dest = File(destination.path)
- val newFile = File(dest, tempFile.name)
- val isReplace = newFile.exists()
-
- if (tempFile.exists() && dest.exists()) {
- if (tempFile.renameTo(newFile)) {
- if (isReplace) replaced++ else completed++
- }
- }
- }
-
- filesToMove.add(it.path)
-
- if (!it.isFile) {
- filesToMove.addAll(it.walk(true).map { f -> f.path })
- }
- }
-
-
- total = filesToMove.size
-
- fun updateProgress(info: String): FilesTabTaskDetails {
- return details.apply {
- subtitle =
- globalClass.getString(R.string.progress, completed + skipped + replaced, total)
- if (progress >= 0) this.progress =
- (completed + replaced + skipped) / total.toFloat()
- if (info.isNotEmpty()) this.info = info
- }
- }
-
- fun copyFile(from: DocumentHolder, to: DocumentHolder) {
- if (!filesToMove.contains(from.path)) return
-
- val existingFile = from.getName().let { to.findFile(it) }
-
- taskCallback.onReport(
- updateProgress(
- info = globalClass.getString(
- R.string.moving_destination,
- from.getName(),
- destination.getName()
- )
- )
- )
-
- if (existingFile isNot null) {
- if (from.path == existingFile?.path || !existingFile!!.delete()) {
- skipped++
- filesSkipped.add(from.path)
- return
- }
- }
-
- from.getName().let { fileName ->
- to.createSubFile(fileName)?.let { target ->
- val inStream = from.openInputStream()
- val outStream = target.openOutputStream()
-
- inStream.use { input ->
- outStream.use { output ->
- input?.copyTo(output ?: return)
- }
- }
-
- if (existingFile isNot null) replaced++ else completed++
-
- taskCallback.onReport(updateProgress(emptyString))
-
- return
- }
- }
-
- skipped++
- filesSkipped.add(from.path)
- }
-
- fun copyFolder(from: DocumentHolder, to: DocumentHolder) {
- if (!filesToMove.contains(from.path)) return
-
- val newFolder = from.getName().let { to.createSubFolder(it) }
-
- if (newFolder isNot null) {
- if (from.path == newFolder?.path) {
- skipped++
- filesSkipped.add(from.path)
- } else {
- completed++
- }
-
- from.listContent(false).forEach { currentFile ->
- if (currentFile.isFile) {
- copyFile(currentFile, newFolder!!)
- } else {
- copyFolder(currentFile, newFolder!!)
- }
- }
- } else {
- skipped++
- filesSkipped.add(from.path)
- }
- }
-
- source.forEach { currentFile ->
- if (currentFile.exists()) {
- if (currentFile.isFile) {
- copyFile(currentFile, destination)
- } else {
- copyFolder(currentFile, destination)
- }
- }
- }
-
- details.progress = -1f
- taskCallback.onReport(
- updateProgress(
- info = globalClass.getString(R.string.deleting_source_files)
- )
- )
-
- source.forEach { currentFile ->
- if (!filesSkipped.contains(currentFile.path)) {
- if (currentFile.exists()) {
- if (currentFile.isFile) currentFile.delete()
- else currentFile.deleteRecursively()
- }
- }
- }
-
- launchOnUiThread {
- taskCallback.onComplete(details.apply {
- subtitle = globalClass.getString(R.string.done)
- })
-
- postActions.forEach {
- it.due = true
- }
- }
- }
-
- override fun getIcon(): ImageVector = Icons.Rounded.ContentCut
-
- override fun getSourceFiles(): List {
- return source
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/RenameTask.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/RenameTask.kt
new file mode 100644
index 00000000..3b70f010
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/RenameTask.kt
@@ -0,0 +1,272 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder
+import net.lingala.zip4j.ZipFile
+import java.io.File
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class RenameTask(val sourceContent: List) : Task() {
+ private var parameters: RenameTaskParameters? = null
+ private var pendingContent = arrayListOf()
+
+ override val metadata = System.currentTimeMillis().toFormattedDate().let { time ->
+ TaskMetadata(
+ id = id,
+ creationTime = time,
+ title = globalClass.resources.getString(R.string.rename),
+ subtitle = globalClass.resources.getString(R.string.task_subtitle, sourceContent.size),
+ displayDetails = sourceContent.joinToString(", ") { it.displayName },
+ fullDetails = buildString {
+ sourceContent.forEachIndexed { index, source ->
+ append(source.displayName)
+ append("\n")
+ }
+ append("\n")
+ append(time)
+ },
+ isCancellable = true,
+ canMoveToBackground = false
+ )
+ }
+
+ override val progressMonitor = TaskProgressMonitor(
+ status = TaskStatus.PENDING,
+ taskTitle = metadata.title,
+ )
+
+ override fun getCurrentStatus() = progressMonitor.status
+
+ override fun validate() = sourceContent.find { !it.isValid() } == null
+
+ private fun markAsFailed(info: String) {
+ progressMonitor.apply {
+ status = TaskStatus.FAILED
+ summary = info
+ }
+ }
+
+ override suspend fun run() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
+ }
+ run(parameters!!)
+ }
+
+ override suspend fun run(params: TaskParameters) {
+ parameters = params as RenameTaskParameters
+ progressMonitor.status = TaskStatus.RUNNING
+ protect = false
+
+ if (sourceContent.isEmpty()) {
+ markAsFailed(globalClass.resources.getString(R.string.task_summary_no_src))
+ return
+ }
+
+ // Validate the regular expression
+ try {
+ if (parameters!!.toFind.isNotEmpty() && parameters!!.useRegex) parameters!!.toFind.toRegex()
+ } catch (e: Exception) {
+ logger.logError(e)
+ markAsFailed(
+ globalClass.resources.getString(
+ R.string.task_summary_failed,
+ e.message ?: emptyString
+ )
+ )
+ return
+ }
+
+ progressMonitor.processName = globalClass.resources.getString(R.string.preparing)
+
+ if (pendingContent.isEmpty()) {
+ sourceContent.forEachIndexed { index, content ->
+ pendingContent.add(
+ RenameContentItem(
+ source = content,
+ newPath = getNewPath(
+ content = content,
+ newName = parameters!!.newName,
+ index = index,
+ ),
+ status = TaskContentStatus.PENDING
+ )
+ )
+ }
+ }
+
+ progressMonitor.apply {
+ totalContent = pendingContent.size
+ processName = globalClass.getString(R.string.renaming)
+ }
+
+ pendingContent.forEachIndexed { index, itemToRename ->
+ if (aborted) {
+ progressMonitor.status = TaskStatus.PAUSED
+ return
+ }
+
+ if (itemToRename.status == TaskContentStatus.PENDING) {
+ progressMonitor.apply {
+ contentName = itemToRename.source.displayName
+ remainingContent = pendingContent.size - (index + 1)
+ progress = (index + 1f) / pendingContent.size
+ }
+
+ try {
+ if (itemToRename.source is LocalFileHolder) {
+ itemToRename.source.file.renameTo(File(itemToRename.newPath))
+ } else if (itemToRename.source is ZipFileHolder) {
+ ZipFile(itemToRename.source.zipTree.source.file).renameFile(
+ itemToRename.source.uniquePath,
+ itemToRename.newPath
+ )
+ }
+ itemToRename.status = TaskContentStatus.SUCCESS
+ } catch (e: Exception) {
+ logger.logError(e)
+ markAsFailed(
+ globalClass.resources.getString(
+ R.string.task_summary_failed,
+ e.message ?: emptyString
+ )
+ )
+ return
+ }
+ }
+ }
+
+ if (progressMonitor.status == TaskStatus.RUNNING) {
+ progressMonitor.status = TaskStatus.SUCCESS
+ progressMonitor.summary = buildString {
+ pendingContent.forEach { content ->
+ append(content.source.displayName)
+ append(" -> ")
+ append(content.status.name)
+ }
+ }
+ }
+ }
+
+ override suspend fun continueTask() {
+ if (parameters == null) {
+ markAsFailed(globalClass.getString(R.string.unable_to_continue_task))
+ return
+ }
+ run(parameters!!)
+ }
+
+ override fun setParameters(params: TaskParameters) {
+ parameters = params as RenameTaskParameters
+ }
+
+ private fun getNewPath(
+ content: ContentHolder,
+ newName: String,
+ index: Int,
+ ): String {
+ val file = File(content.uniquePath)
+
+ // 1. Deconstruct the original path and file name
+ val parentPath = file.parent?.let { "$it/" } ?: emptyString
+ val originalFileName = file.name
+
+ if (sourceContent.size == 1) return parentPath + newName + if (content.uniquePath.endsWith(
+ File.separator
+ )
+ ) File.separator else emptyString
+
+ val newFileName = originalFileName.transformFileName(
+ newName = newName,
+ index = index,
+ textToFind = parameters!!.toFind,
+ replaceText = parameters!!.toReplace,
+ useRegex = parameters!!.useRegex,
+ onLastModified = { content.lastModified }
+ )
+
+ // 2. Reconstruct the full path
+ // Also, preserve the trailing slash if the original path was a directory
+ return parentPath + newFileName + if (content.uniquePath.endsWith(File.separatorChar) && !newFileName.endsWith(
+ File.separator
+ )
+ ) File.separator else emptyString
+ }
+
+ companion object {
+ /**
+ * Pattern Syntax:
+ * - {p} -> file name without extension (e.g., "document" from "document.txt")
+ * - {s} -> file extension (e.g., "txt" from "document.txt")
+ * - {e} -> file extension with a dot (e.g., ".txt" from "document.txt")
+ * - {t} -> file's last modified time (formatted as "yyyyMMdd_HHmmss")
+ * - {n} -> number increment (e.g., {0} -> 0, 1, 2...; {1} -> 1, 2, 3...)
+ * - {zn} -> zero-padded number increment (e.g., {z0} -> 0, 1...; {zz0} -> 00, 01...; {zzz1} -> 001, 002...)
+ */
+ fun String.transformFileName(
+ newName: String,
+ index: Int,
+ textToFind: String,
+ replaceText: String,
+ useRegex: Boolean,
+ onLastModified: () -> Long
+ ): String {
+ val nameWithoutExtension = substringBeforeLast('.', missingDelimiterValue = this)
+ val extension = substringAfterLast('.', missingDelimiterValue = emptyString)
+ val lastModifiedTime by lazy {
+ val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
+ sdf.format(Date(onLastModified()))
+ }
+ val placeholderRegex = Regex("""\{([^{}]+)\}""")
+ val zeroPaddedRegex = Regex("""^(z+)(\d+)$""")
+ var newFileName = newName.replace(placeholderRegex) { matchResult ->
+ val token = matchResult.groupValues[1]
+ val zMatch = zeroPaddedRegex.matchEntire(token)
+ when {
+ token == "p" -> nameWithoutExtension
+ token == "s" -> extension
+ token == "e" -> if (extension.isEmpty()) emptyString else ".${extension}"
+ token == "t" -> lastModifiedTime
+ zMatch != null -> {
+ val zPart = zMatch.groupValues[1]
+ val numberPart = zMatch.groupValues[2]
+ val paddingWidth = zPart.length
+ val startNumber = numberPart.toInt()
+ (startNumber + index).toString().padStart(paddingWidth, '0')
+ }
+
+ token.toIntOrNull() != null -> {
+ val startNumber = token.toInt()
+ (startNumber + index).toString()
+ }
+
+ else -> matchResult.value
+ }
+ }
+
+ if (textToFind.isNotEmpty()) {
+ newFileName = when (useRegex) {
+ true -> newFileName.replace(textToFind.toRegex(), replaceText)
+ false -> newFileName.replace(textToFind, replaceText)
+ }
+ }
+
+ return newFileName
+ }
+ }
+
+ internal data class RenameContentItem(
+ val source: ContentHolder,
+ val newPath: String,
+ var status: TaskContentStatus
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/Task.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/Task.kt
new file mode 100644
index 00000000..5186d3f6
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/Task.kt
@@ -0,0 +1,34 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+import java.util.UUID
+
+abstract class Task(
+ val id: String = UUID.randomUUID().toString()
+) {
+ var commonConflictResolution = TaskContentStatus.ASK
+
+ @Volatile
+ var aborted = false
+
+ /**
+ * Used to protect task from the time it is added to the running list and gets actually run
+ */
+ var protect = false
+
+ open fun overrideConflicts(resolution: TaskContentStatus) {
+ commonConflictResolution = resolution
+ }
+
+ open fun abortTask() {
+ aborted = true
+ }
+
+ abstract val metadata: TaskMetadata
+ abstract val progressMonitor: TaskProgressMonitor
+ abstract fun getCurrentStatus(): TaskStatus
+ abstract fun validate(): Boolean
+ abstract suspend fun run(params: TaskParameters)
+ abstract suspend fun run()
+ abstract suspend fun continueTask()
+ abstract fun setParameters(params: TaskParameters)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskContentItem.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskContentItem.kt
new file mode 100644
index 00000000..a951d8eb
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskContentItem.kt
@@ -0,0 +1,9 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+
+data class TaskContentItem(
+ val content: ContentHolder,
+ val relativePath: String,
+ var status: TaskContentStatus
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskManager.kt
new file mode 100644
index 00000000..89b6fdb4
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskManager.kt
@@ -0,0 +1,304 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.showMsg
+import com.raival.compose.file.explorer.screen.main.tab.files.service.ContentOperationService.Companion.startNewBackgroundTask
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.util.concurrent.ConcurrentHashMap
+
+class TaskManager {
+ private val taskScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private val taskMutex = Mutex()
+
+ // Use thread-safe collections
+ private val allTasks = ConcurrentHashMap()
+
+ val pendingTasks = mutableListOf()
+ val runningTasks = mutableListOf()
+ val pausedTasks = mutableListOf()
+ val failedTasks = mutableListOf()
+ val invalidTasks = mutableListOf()
+ val completedTasks = mutableListOf()
+
+ private var isMonitoring = false
+
+ @Volatile
+ var runningTaskDialogInfo = RunningTaskDialogInfo()
+ val taskInterceptor = TaskConflict()
+
+ suspend fun addTask(task: Task, notifyUser: Boolean = true) = taskMutex.withLock {
+ if (task.validate()) {
+ allTasks[task.id] = task
+ pendingTasks.add(task)
+ if (notifyUser)
+ globalClass.showMsg(globalClass.resources.getString(R.string.new_task_has_been_added))
+ } else {
+ invalidTasks.add(task)
+ globalClass.showMsg(globalClass.getString(R.string.task_validation_failed))
+ }
+ }
+
+ suspend fun addTaskAndRun(task: Task, parameters: TaskParameters) {
+ addTask(task, false)
+ if (allTasks.containsKey(task.id)) {
+ runTask(task.id, parameters)
+ }
+ }
+
+ suspend fun removeTask(id: String) = taskMutex.withLock {
+ allTasks[id]?.abortTask()
+ allTasks.remove(id)
+
+ pendingTasks.removeIf { it.id == id }
+ runningTasks.removeIf { it.id == id }
+ pausedTasks.removeIf { it.id == id }
+ failedTasks.removeIf { it.id == id }
+ invalidTasks.removeIf { it.id == id }
+ completedTasks.removeIf { it.id == id }
+ }
+
+ suspend fun validateTasks() = taskMutex.withLock {
+ val iterator = pendingTasks.iterator()
+ while (iterator.hasNext()) {
+ val task = iterator.next()
+ if (!task.validate()) {
+ invalidTasks.add(task)
+ // Use the iterator's remove method, which is safe
+ iterator.remove()
+ }
+ }
+ }
+
+ suspend fun runTask(id: String, taskParameters: TaskParameters) = taskMutex.withLock {
+ val task = allTasks[id]
+ if (task == null) {
+ showMsg(globalClass.getString(R.string.task_not_found))
+ return@withLock
+ }
+
+ if (task.getCurrentStatus() != TaskStatus.PENDING) {
+ showMsg(globalClass.getString(R.string.task_is_not_in_pending_state))
+ return@withLock
+ }
+
+ moveTaskToRunning(task)
+ task.setParameters(taskParameters)
+ bringToForeground(task)
+ startNewBackgroundTask(globalClass, task.id)
+ }
+
+ suspend fun continueTask(taskId: String) = taskMutex.withLock {
+ val task = allTasks[taskId]
+ if (task == null) {
+ showMsg(globalClass.getString(R.string.task_not_found))
+ return@withLock
+ }
+
+ val currentStatus = task.getCurrentStatus()
+
+ if (currentStatus != TaskStatus.CONFLICT
+ && currentStatus != TaskStatus.FAILED
+ && currentStatus != TaskStatus.PAUSED
+ ) {
+ showMsg(
+ globalClass.getString(
+ R.string.task_cannot_be_continued_from_current_state,
+ currentStatus
+ )
+ )
+ return@withLock
+ }
+
+ // Move task back to running
+ pausedTasks.removeIf { it.id == taskId }
+ failedTasks.removeIf { it.id == taskId }
+
+ if (!runningTasks.any { it.id == taskId }) {
+ runningTasks.add(task)
+ }
+
+ bringToForeground(task)
+ startNewBackgroundTask(globalClass, task.id)
+ }
+
+ private fun moveTaskToRunning(task: Task) {
+ pendingTasks.removeIf { it.id == task.id }
+ pausedTasks.removeIf { it.id == task.id }
+ failedTasks.removeIf { it.id == task.id }
+
+ if (!runningTasks.any { it.id == task.id }) {
+ runningTasks.add(task)
+ }
+ }
+
+ suspend fun handleTaskStatusChange(task: Task, newStatus: TaskStatus) = taskMutex.withLock {
+ when (newStatus) {
+ TaskStatus.SUCCESS -> {
+ runningTasks.removeIf { it.id == task.id }
+ completedTasks.add(task)
+ showMsg(globalClass.getString(R.string.task_completed))
+ }
+
+ TaskStatus.FAILED -> {
+ runningTasks.removeIf { it.id == task.id }
+ failedTasks.add(task)
+ showMsg(globalClass.getString(R.string.task_failed))
+ }
+
+ TaskStatus.PAUSED -> {
+ runningTasks.removeIf { it.id == task.id }
+ pausedTasks.add(task)
+ showMsg(globalClass.getString(R.string.task_paused))
+ }
+
+ TaskStatus.CONFLICT -> {
+ runningTasks.removeIf { it.id == task.id }
+ pausedTasks.add(task)
+ // Conflict will be handled by the UI
+ }
+
+ else -> {
+ // No action needed for other states
+ }
+ }
+ }
+
+ fun getTask(id: String): Task? = allTasks[id]
+
+ fun overrideConflicts(taskId: String, resolution: TaskContentStatus) {
+ allTasks[taskId]?.overrideConflicts(resolution)
+ }
+
+ fun bringToForeground(task: Task) {
+ if (!isMonitoring) {
+ taskScope.launch {
+ monitorRunningTask(task)
+ }
+ }
+ }
+
+ private suspend fun monitorRunningTask(task: Task) {
+ isMonitoring = true
+ runningTaskDialogInfo.show(task)
+ try {
+ while (runningTaskDialogInfo.showDialog) {
+ if (!runningTasks.contains(task)) {
+ break
+ }
+
+ runningTaskDialogInfo.updateInfo(task.progressMonitor)
+ delay(100)
+ }
+ } finally {
+ isMonitoring = false
+ runningTaskDialogInfo.hide()
+ }
+ }
+
+ fun hideRunningTaskDialog() {
+ runningTaskDialogInfo.hide()
+ }
+
+ class TaskConflict {
+ var hasConflict by mutableStateOf(false)
+ private set
+ var message by mutableStateOf(emptyString)
+ private set
+ var taskContentItem by mutableStateOf(null)
+ private set
+ var task by mutableStateOf(null)
+ private set
+
+ fun interceptTask(taskContentItem: TaskContentItem, task: Task) {
+ this.hasConflict = true
+ this.message = globalClass.resources.getString(
+ R.string.file_already_exists,
+ taskContentItem.content.displayName
+ )
+ this.taskContentItem = taskContentItem
+ this.task = task
+ }
+
+ private fun reset() {
+ hasConflict = false
+ task = null
+ message = emptyString
+ taskContentItem = null
+ }
+
+ fun hide() {
+ reset()
+ }
+
+ fun resolve(resolution: TaskContentStatus, applyToAllConflicts: Boolean = false) {
+ val currentTask = task
+ val currentItem = taskContentItem
+
+ if (currentTask == null || currentItem == null) return
+
+ currentItem.status = resolution
+
+ if (applyToAllConflicts) {
+ globalClass.taskManager.overrideConflicts(currentTask.id, resolution)
+ }
+
+ // Reset before continuing to avoid race conditions
+ hide()
+
+ // Continue the task
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.taskManager.continueTask(currentTask.id)
+ }
+ }
+ }
+
+ class RunningTaskDialogInfo {
+ var linkedTask by mutableStateOf(null)
+ var showDialog by mutableStateOf(false)
+ private set
+ var progressMonitor by mutableStateOf(null)
+
+ fun show(task: Task) {
+ if (showDialog) return
+
+ linkedTask = task
+ showDialog = true
+ }
+
+ fun hide() {
+ if (!showDialog) return
+
+ showDialog = false
+ reset()
+ }
+
+ private fun reset() {
+ linkedTask = null
+ progressMonitor = null
+ }
+
+ fun updateInfo(progressMonitor: TaskProgressMonitor) {
+ this.progressMonitor = TaskProgressMonitor(
+ status = progressMonitor.status,
+ taskTitle = progressMonitor.taskTitle,
+ processName = progressMonitor.processName,
+ contentName = progressMonitor.contentName,
+ totalContent = progressMonitor.totalContent,
+ remainingContent = progressMonitor.remainingContent,
+ progress = progressMonitor.progress,
+ summary = progressMonitor.summary
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskMetadata.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskMetadata.kt
new file mode 100644
index 00000000..aa6a0740
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskMetadata.kt
@@ -0,0 +1,12 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+data class TaskMetadata(
+ val id: String,
+ val creationTime: String,
+ val title: String,
+ val subtitle: String,
+ val displayDetails: String,
+ val fullDetails: String,
+ val isCancellable: Boolean,
+ val canMoveToBackground: Boolean
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskParameters.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskParameters.kt
new file mode 100644
index 00000000..2381e2d3
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskParameters.kt
@@ -0,0 +1,26 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+
+interface TaskParameters
+
+data class CopyTaskParameters(
+ val destHolder: ContentHolder
+) : TaskParameters
+
+class DeleteTaskParameters : TaskParameters
+
+data class CompressTaskParameters(
+ val destPath: String
+) : TaskParameters
+
+data class RenameTaskParameters(
+ val newName: String,
+ val toFind: String,
+ val toReplace: String,
+ val useRegex: Boolean
+) : TaskParameters
+
+data class ApksMergeTaskParameters(
+ val autoSign: Boolean
+) : TaskParameters
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskProgressMonitor.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskProgressMonitor.kt
new file mode 100644
index 00000000..77c3f9ac
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskProgressMonitor.kt
@@ -0,0 +1,14 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+import com.raival.compose.file.explorer.common.extension.emptyString
+
+data class TaskProgressMonitor(
+ var status: TaskStatus = TaskStatus.PENDING, // Pending, Running, Success, Failed, Cancelled
+ var taskTitle: String = emptyString, // e.g, "Copy", "Move"
+ var processName: String = emptyString, // e.g, "Copying", "Moving"
+ var contentName: String = emptyString, // e.g, "File 1", "File 2". can be empty when not processing files
+ var totalContent: Int = 0, // Total number of content to be processed
+ var remainingContent: Int = 0, // Remaining number of content to be processed
+ var progress: Float = -1f, // Progress of the task from 0 to 1
+ var summary: String = emptyString // Summary of the task after it stops
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskStatus.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskStatus.kt
new file mode 100644
index 00000000..5a414448
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/task/TaskStatus.kt
@@ -0,0 +1,20 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.task
+
+enum class TaskStatus {
+ PENDING,
+ RUNNING,
+ SUCCESS,
+ FAILED,
+ PAUSED,
+ CONFLICT
+}
+
+enum class TaskContentStatus {
+ PENDING,
+ SUCCESS,
+ FAILED,
+ CONFLICT,
+ SKIP,
+ REPLACE,
+ ASK
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BookmarksDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BookmarksDialog.kt
deleted file mode 100644
index fcf56f95..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BookmarksDialog.kt
+++ /dev/null
@@ -1,124 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.material.ExperimentalMaterialApi
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Delete
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.kevinnzou.compose.swipebox.SwipeBox
-import com.kevinnzou.compose.swipebox.SwipeDirection
-import com.kevinnzou.compose.swipebox.widget.SwipeIcon
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments
-import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
-import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-
-@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
-@Composable
-fun BookmarksDialog(tab: FilesTab) {
- if (tab.showBookmarkDialog) {
- val context = LocalContext.current
- BottomSheetDialog(
- onDismissRequest = { tab.showBookmarkDialog = false }
- ) {
- Text(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .fillMaxWidth(),
- text = stringResource(R.string.bookmarks),
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center
- )
- Space(size = 8.dp)
- LazyColumn(Modifier.animateContentSize()) {
- itemsIndexed(
- globalClass.filesTabManager.bookmarks
- .map { DocumentHolder.fromFullPath(it) }
- .takeWhile { it != null } as ArrayList,
- key = { index, item -> item.path }
- ) { index, item ->
- SwipeBox(
- modifier = Modifier.fillMaxWidth(),
- swipeDirection = SwipeDirection.EndToStart,
- endContentWidth = 60.dp,
- endContent = { swipeableState, endSwipeProgress ->
- SwipeIcon(
- imageVector = Icons.Outlined.Delete,
- contentDescription = stringResource(R.string.delete),
- tint = Color.White,
- background = Color(0xFFFA1E32),
- weight = 1f,
- iconSize = 20.dp
- ) {
- globalClass.filesTabManager.bookmarks -= item.path
- }
- }
- ) { _, _, _ ->
- Modifier
- .fillMaxWidth()
- Column(
- Modifier
- .animateItem()
- .combinedClickable(
- onClick = {
- if (item.isFile) {
- tab.openFile(context, item)
- } else {
- tab.requestNewTab(FilesTab(item))
- }
- tab.showBookmarkDialog = false
- },
- onLongClick = { }
- )
- ) {
- Space(size = 4.dp)
- FileItemRow(
- item = item,
- fileDetails = item.path.trimToLastTwoSegments()
- )
- Space(size = 4.dp)
- HorizontalDivider(
- modifier = Modifier.padding(start = 56.dp),
- thickness = 0.5.dp
- )
- }
- }
- }
- }
- AnimatedVisibility(globalClass.filesTabManager.bookmarks.isEmpty()) {
- Text(
- modifier = Modifier
- .padding(vertical = 60.dp)
- .fillMaxWidth()
- .alpha(0.26f),
- text = stringResource(R.string.empty),
- fontSize = 24.sp,
- textAlign = TextAlign.Center
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt
index 9aa59c2d..0b234831 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBar.kt
@@ -1,7 +1,13 @@
package com.raival.compose.file.explorer.screen.main.tab.files.ui
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Sort
@@ -12,14 +18,23 @@ import androidx.compose.material.icons.rounded.DeleteSweep
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.SelectAll
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.common.ui.block
import com.raival.compose.file.explorer.common.ui.detectVerticalSwipe
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FileSortingMenuDialog
@Composable
fun BottomOptionsBar(tab: FilesTab) {
@@ -40,7 +55,7 @@ fun BottomOptionsBar(tab: FilesTab) {
BottomOptionsBarButton(Icons.Rounded.DeleteSweep, stringResource(R.string.empty)) {
tab.unselectAllFiles(false)
tab.activeFolderContent.forEach {
- tab.selectedFiles[it.path] = it
+ tab.selectedFiles[it.uniquePath] = it
}
tab.quickReloadFiles()
tab.showConfirmDeleteDialog = true
@@ -55,20 +70,20 @@ fun BottomOptionsBar(tab: FilesTab) {
tab.showSearchPenal = true
}
- if (!tab.showEmptyRecycleBin && tab.canCreateNewFile()) {
+ if (!tab.showEmptyRecycleBin && tab.canCreateNewContent) {
BottomOptionsBarButton(Icons.Rounded.Add, stringResource(R.string.create)) {
tab.showCreateNewFileDialog = true
}
}
- if (tab.showMoreOptionsButton && tab.selectedFiles.isNotEmpty()) {
+ if (tab.showMoreOptionsButton && tab.selectedFiles.isNotEmpty()) {
BottomOptionsBarButton(Icons.Rounded.SelectAll, stringResource(R.string.select_all)) {
if (tab.selectedFiles.size == tab.activeFolderContent.size) {
tab.unselectAllFiles()
} else {
tab.unselectAllFiles(false)
tab.activeFolderContent.forEach {
- tab.selectedFiles[it.path] = it
+ tab.selectedFiles[it.uniquePath] = it
}
tab.quickReloadFiles()
}
@@ -81,7 +96,7 @@ fun BottomOptionsBar(tab: FilesTab) {
} else {
BottomOptionsBarButton(Icons.AutoMirrored.Rounded.Sort, stringResource(R.string.sort), {
if (tab.showSortingMenu) {
- FileSortingMenu(
+ FileSortingMenuDialog(
tab = tab,
reloadFiles = { tab.reloadFiles() }
) { tab.showSortingMenu = false }
@@ -95,4 +110,47 @@ fun BottomOptionsBar(tab: FilesTab) {
}
}
}
-}
\ No newline at end of file
+}
+
+@Composable
+fun RowScope.BottomOptionsBarButton(
+ imageVector: ImageVector,
+ text: String,
+ view: @Composable () -> Unit = {},
+ onClick: () -> Unit
+) {
+ val preferencesManager = globalClass.preferencesManager
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .clickable {
+ onClick()
+ }
+ .padding(vertical = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ if (!preferencesManager.appearancePrefs.showBottomBarLabels) {
+ Space(size = 4.dp)
+ }
+
+ Icon(
+ modifier = Modifier.size(20.dp),
+ imageVector = imageVector,
+ contentDescription = null
+ )
+
+ Space(size = 4.dp)
+
+ if (preferencesManager.appearancePrefs.showBottomBarLabels) {
+ Text(
+ text = text,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ view()
+ }
+}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBarButton.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBarButton.kt
deleted file mode 100644
index 55fe69fa..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/BottomOptionsBarButton.kt
+++ /dev/null
@@ -1,62 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.RowScope
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.common.ui.Space
-
-@Composable
-fun RowScope.BottomOptionsBarButton(
- imageVector: ImageVector,
- text: String,
- view: @Composable () -> Unit = {},
- onClick: () -> Unit
-) {
- val preferencesManager = globalClass.preferencesManager
-
- Column(
- modifier = Modifier
- .weight(1f)
- .clickable {
- onClick()
- }
- .padding(vertical = 8.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- if (!preferencesManager.displayPrefs.showBottomBarLabels) {
- Space(size = 4.dp)
- }
-
- Icon(
- modifier = Modifier.size(20.dp),
- imageVector = imageVector,
- contentDescription = null
- )
-
- Space(size = 4.dp)
-
- if (preferencesManager.displayPrefs.showBottomBarLabels) {
- Text(
- text = text,
- fontSize = 11.sp,
- fontWeight = FontWeight.Medium
- )
- }
-
- view()
- }
-}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/CreateNewFileDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/CreateNewFileDialog.kt
deleted file mode 100644
index 2778187e..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/CreateNewFileDialog.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.isValidAsFileName
-import com.raival.compose.file.explorer.common.ui.InputDialog
-import com.raival.compose.file.explorer.common.ui.InputDialogButton
-import com.raival.compose.file.explorer.common.ui.InputDialogInput
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-
-@Composable
-fun CreateNewFileDialog(tab: FilesTab) {
- if (tab.showCreateNewFileDialog) {
- InputDialog(
- title = stringResource(R.string.create_new),
- inputs = arrayListOf(
- InputDialogInput(stringResource(R.string.name)) {
- if (!it.isValidAsFileName()) {
- globalClass.getString(R.string.invalid_file_name)
- } else {
- null
- }
- }
- ),
- buttons = arrayListOf(
- InputDialogButton(stringResource(R.string.file)) { inputs ->
- val input = inputs[0]
- if (input.content.isValidAsFileName()) {
- val similarFile = tab.activeFolder.findFile(input.content)
- if (similarFile == null) {
- tab.activeFolder.createSubFile(input.content)
- tab.showCreateNewFileDialog = false
-
- if (tab.activeFolder.findFile(input.content) == null) {
- globalClass.showMsg(R.string.failed_to_create_file)
- } else {
- tab.onNewFileCreated(input.content)
- }
- } else {
- globalClass.showMsg(R.string.similar_file_exists)
- }
- } else {
- globalClass.showMsg(R.string.invalid_file_name)
- }
- },
- InputDialogButton(stringResource(R.string.folder)) { inputs ->
- val input = inputs[0]
- if (input.content.isValidAsFileName()) {
- val similarFile = tab.activeFolder.findFile(input.content)
- if (similarFile == null) {
- tab.activeFolder.createSubFolder(input.content)
- tab.showCreateNewFileDialog = false
-
- if (tab.activeFolder.findFile(input.content) == null) {
- globalClass.showMsg(R.string.failed_to_create_folder)
- } else {
- tab.onNewFileCreated(input.content)
- }
- } else {
- globalClass.showMsg(R.string.similar_file_exists)
- }
- } else {
- globalClass.showMsg(R.string.invalid_folder_name)
- }
- }
- )
- ) {
- tab.showCreateNewFileDialog = false
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileCompressionDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileCompressionDialog.kt
deleted file mode 100644
index 9e1b5f9e..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileCompressionDialog.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.isValidAsFileName
-import com.raival.compose.file.explorer.common.ui.InputDialog
-import com.raival.compose.file.explorer.common.ui.InputDialogButton
-import com.raival.compose.file.explorer.common.ui.InputDialogInput
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-
-@Composable
-fun FileCompressionDialog(tab: FilesTab) {
- if (tab.compressDialog.showCompressDialog) {
- InputDialog(
- title = stringResource(R.string.create_archive),
- inputs = arrayListOf(
- InputDialogInput(
- stringResource(R.string.name),
- "${tab.activeFolder.getName()}.zip"
- ) {
- if (!it.isValidAsFileName()) {
- globalClass.getString(R.string.invalid_file_name)
- } else {
- null
- }
- }
- ),
- buttons = arrayListOf(
- InputDialogButton(stringResource(R.string.create)) { inputs ->
- val input = inputs[0]
- if (input.content.isValidAsFileName()) {
- val zip = tab.activeFolder.createSubFile(input.content)
- ?: tab.activeFolder.findFile(input.content)
-
- if (zip != null) {
- tab.showTasksPanel = false
- tab.compressDialog.hide()
- CoroutineScope(Dispatchers.IO).launch {
- (tab.compressDialog.task)?.execute(zip, tab.taskCallback)
- }
- } else {
- globalClass.showMsg(R.string.unable_to_create_file)
- }
- } else {
- globalClass.showMsg(R.string.invalid_folder_name)
- }
- },
- InputDialogButton(stringResource(R.string.cancel)) {
- tab.compressDialog.hide()
- }
- )
- ) {
- tab.compressDialog.hide()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileIcon.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileIcon.kt
deleted file mode 100644
index 5f10135e..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileIcon.kt
+++ /dev/null
@@ -1,92 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import android.annotation.SuppressLint
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Folder
-import androidx.compose.material3.Icon
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.FilterQuality
-import androidx.compose.ui.layout.ContentScale
-import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
-import coil3.request.ImageRequest
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
-import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize
-import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap
-
-@Composable
-fun FileIcon(
- documentHolder: DocumentHolder,
- onClickListener: (() -> Unit)? = null
-) {
- val preferencesManager = globalClass.preferencesManager
-
- val iconSize = when (preferencesManager.displayPrefs.fileListSize) {
- FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.IconSize.SMALL.dp
- FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.IconSize.MEDIUM.dp
- FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.IconSize.LARGE.dp
- else -> FilesTabFileListSizeMap.IconSize.EXTRA_LARGE.dp
- }
-
- if (documentHolder.isFile) {
- ItemRowIcon(
- icon = documentHolder,
- alpha = if (documentHolder.isHidden) 0.4f else 1f,
- onClickListener = onClickListener,
- placeholder = documentHolder.getFileIconResource()
- )
- } else {
- Icon(
- modifier = Modifier
- .size(iconSize)
- .clip(RoundedCornerShape(4.dp))
- .clickable {
- onClickListener?.invoke()
- }
- .alpha(if (documentHolder.isHidden) 0.4f else 1f),
- imageVector = Icons.Rounded.Folder,
- contentDescription = null
- )
- }
-}
-
-@SuppressLint("CheckResult", "UseCompatLoadingForDrawables")
-@Composable
-fun ItemRowIcon(
- icon: Any?,
- alpha: Float = 1f,
- onClickListener: (() -> Unit)? = null,
- placeholder: Int,
-) {
- val preferencesManager = globalClass.preferencesManager
-
- val iconSize = when (preferencesManager.displayPrefs.fileListSize) {
- FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.IconSize.SMALL.dp
- FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.IconSize.MEDIUM.dp
- FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.IconSize.LARGE.dp
- else -> FilesTabFileListSizeMap.IconSize.EXTRA_LARGE.dp
- }
-
- val modifier = Modifier
- .size(iconSize)
- .clip(RoundedCornerShape(4.dp))
- .then(if (onClickListener != null) Modifier.clickable { onClickListener() } else Modifier)
-
- AsyncImage(
- modifier = modifier,
- model = ImageRequest.Builder(globalClass).data(icon).build(),
- filterQuality = FilterQuality.Low,
- error = painterResource(id = placeholder),
- contentScale = ContentScale.Fit,
- alpha = alpha,
- contentDescription = null
- )
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt
index 1a58127a..bbe97189 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileItemRow.kt
@@ -1,38 +1,51 @@
package com.raival.compose.file.explorer.screen.main.tab.files.ui
+import android.annotation.SuppressLint
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.FilterQuality
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import coil3.compose.AsyncImage
+import coil3.request.ImageRequest
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize
-import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap
+import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListFontSize
+import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListIconSize
@Composable
fun FileItemRow(
- item: DocumentHolder,
+ item: ContentHolder,
fileDetails: String,
namePrefix: String = emptyString,
onFileIconClick: (() -> Unit)? = null
) {
ItemRow(
- title = namePrefix + item.getName(),
+ title = namePrefix + item.displayName,
subtitle = fileDetails,
icon = {
FileIcon(
- documentHolder = item,
+ contentHolder = item,
onClickListener = onFileIconClick
)
}
@@ -53,7 +66,7 @@ fun ItemRow(
.fillMaxWidth()
.then(if (onItemClick != null) Modifier.clickable { onItemClick() } else Modifier)) {
Space(
- size = when (preferencesManager.displayPrefs.fileListSize) {
+ size = when (preferencesManager.fileListPrefs.itemSize) {
FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp
else -> 4.dp
}
@@ -70,12 +83,7 @@ fun ItemRow(
Column(
Modifier.weight(1f)
) {
- val fontSize = when (preferencesManager.displayPrefs.fileListSize) {
- FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.FontSize.SMALL
- FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.FontSize.MEDIUM
- FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.FontSize.LARGE
- else -> FilesTabFileListSizeMap.FontSize.EXTRA_LARGE
- }
+ val fontSize = getFileListFontSize()
Text(
text = title,
@@ -96,12 +104,66 @@ fun ItemRow(
}
Space(
- size = when (preferencesManager.displayPrefs.fileListSize) {
+ size = when (preferencesManager.fileListPrefs.itemSize) {
FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp
else -> 4.dp
}
)
}
+}
+
+
+@Composable
+fun FileIcon(
+ contentHolder: ContentHolder,
+ onClickListener: (() -> Unit)? = null
+) {
+ val iconSize = getFileListIconSize()
+
+ if (contentHolder.isFile()) {
+ ItemRowIcon(
+ icon = contentHolder,
+ alpha = if (contentHolder.isHidden()) 0.4f else 1f,
+ onClickListener = onClickListener,
+ placeholder = contentHolder.iconPlaceholder
+ )
+ } else {
+ Icon(
+ modifier = Modifier
+ .size(iconSize.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .clickable {
+ onClickListener?.invoke()
+ }
+ .alpha(if (contentHolder.isHidden()) 0.4f else 1f),
+ imageVector = Icons.Rounded.Folder,
+ contentDescription = null
+ )
+ }
+}
+@SuppressLint("CheckResult", "UseCompatLoadingForDrawables")
+@Composable
+fun ItemRowIcon(
+ icon: Any?,
+ alpha: Float = 1f,
+ onClickListener: (() -> Unit)? = null,
+ placeholder: Int,
+) {
+ val iconSize = getFileListIconSize()
+ val modifier = Modifier
+ .size(iconSize.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .then(if (onClickListener != null) Modifier.clickable { onClickListener() } else Modifier)
+
+ AsyncImage(
+ modifier = modifier,
+ model = ImageRequest.Builder(globalClass).data(icon).build(),
+ filterQuality = FilterQuality.Low,
+ error = painterResource(id = placeholder),
+ contentScale = ContentScale.Fit,
+ alpha = alpha,
+ contentDescription = null
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOption.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOption.kt
deleted file mode 100644
index 8c340f99..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOption.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.vector.ImageVector
-import androidx.compose.ui.unit.dp
-import com.raival.compose.file.explorer.common.ui.Space
-
-@Composable
-fun FileOption(
- icon: ImageVector,
- text: String,
- highlight: Color = Color.Unspecified,
- onClick: () -> Unit
-) {
- Row(
- Modifier
- .fillMaxWidth()
- .clickable {
- onClick()
- }
- .padding(vertical = 16.dp, horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- modifier = Modifier.size(21.dp),
- imageVector = icon,
- tint = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight,
- contentDescription = null
- )
- Space(size = 12.dp)
- Text(
- text = text,
- color = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesDialog.kt
deleted file mode 100644
index 6382e4fc..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesDialog.kt
+++ /dev/null
@@ -1,168 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableLongStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.toFormattedDate
-import com.raival.compose.file.explorer.common.extension.toFormattedSize
-import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-
-@Composable
-fun FilePropertiesDialog(tab: FilesTab) {
- if (tab.showFileProperties) {
- val targetFiles by remember {
- mutableStateOf(tab.selectedFiles.map { it.value }.toList())
- }
-
- val totalFiles by remember {
- mutableIntStateOf(targetFiles.size)
- }
-
- val selectedFilesCount by remember {
- mutableIntStateOf(targetFiles.count { it.isFile })
- }
-
- val selectedFoldersCount by remember {
- mutableIntStateOf(targetFiles.count { it.isFolder })
- }
-
- var filesCount by remember {
- mutableIntStateOf(0)
- }
-
- var foldersCount by remember {
- mutableIntStateOf(0)
- }
-
- var emptyFoldersCount by remember {
- mutableIntStateOf(0)
- }
-
- var totalSize by remember {
- mutableLongStateOf(0L)
- }
-
- val countingThread = rememberCoroutineScope { Dispatchers.IO }
-
- val scrollState = rememberScrollState()
-
- LaunchedEffect(targetFiles) {
- countingThread.launch {
- targetFiles.forEach {
- it.analyze(
- onCountFile = {
- filesCount++
- },
- onCountFolder = { isEmpty ->
- foldersCount++
- if (isEmpty) emptyFoldersCount++
- },
- onCountSize = { size ->
- totalSize += size
- }
- )
- }
- }
- }
-
- BottomSheetDialog(
- onDismissRequest = { tab.showFileProperties = false }
- ) {
- Text(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .fillMaxWidth(),
- text = stringResource(R.string.properties),
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center
- )
-
- Column(
- Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState)
- ) {
- if (totalFiles == 1) {
- FilePropertiesRow(
- title = stringResource(R.string.name).lowercase(),
- value = targetFiles[0].getName(),
- canCopy = true
- )
-
- FilePropertiesRow(
- title = stringResource(R.string.path),
- value = targetFiles[0].path,
- canCopy = true
- )
-
- FilePropertiesRow(
- title = stringResource(R.string.uri),
- value = targetFiles[0].uri.toString(),
- canCopy = true
- )
-
- FilePropertiesRow(
- title = stringResource(R.string.modification_date),
- value = targetFiles[0].lastModified.toFormattedDate(),
- canCopy = true
- )
- }
-
- FilePropertiesRow(
- title = stringResource(R.string.parent),
- value = tab.activeFolder.path,
- canCopy = true
- )
-
- FilePropertiesRow(
- title = stringResource(R.string.size),
- value = totalSize.toFormattedSize(),
- canCopy = true
- )
-
- FilePropertiesRow(
- title = stringResource(R.string.selected),
- value = stringResource(
- id = R.string.selected_files_count,
- totalFiles,
- selectedFilesCount,
- selectedFoldersCount
- )
- )
-
- FilePropertiesRow(
- title = stringResource(R.string.content),
- value = stringResource(
- id = R.string.content_count,
- filesCount + foldersCount,
- filesCount,
- foldersCount
- )
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesRow.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesRow.kt
deleted file mode 100644
index da20a5fb..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilePropertiesRow.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.copyToClipboard
-import com.raival.compose.file.explorer.common.ui.block
-
-@OptIn(ExperimentalFoundationApi::class)
-@Composable
-fun FilePropertiesRow(
- title: String,
- value: String,
- canCopy: Boolean = false
-) {
- Column(
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp)
- .padding(8.dp)
- ) {
- Text(
- modifier = Modifier.alpha(0.75f),
- text = title,
- fontSize = 14.sp,
- )
- Text(
- modifier = Modifier
- .block(RoundedCornerShape(4.dp))
- .clip(RoundedCornerShape(4.dp))
- .combinedClickable(
- onClick = {},
- onLongClick = {
- if (canCopy) {
- value.copyToClipboard()
- globalClass.showMsg(R.string.copied_to_clipboard)
- }
- }
- )
- .fillMaxWidth()
- .padding(8.dp),
- text = value,
- fontSize = 14.sp
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt
index 62dc237f..85ddd452 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesList.kt
@@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
@@ -53,8 +54,9 @@ import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.ui.Isolate
import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize
-import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap
+import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListFontSize
+import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListIconSize
+import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSizeMap.getFileListSpace
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -63,275 +65,293 @@ import kotlinx.coroutines.withContext
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ColumnScope.FilesList(tab: FilesTab) {
- val context = LocalContext.current
- val documentHolderSelectionHighlightColor = colorScheme.surfaceContainerHigh.copy(alpha = 1f)
- val documentHolderHighlightColor = colorScheme.primary.copy(alpha = 0.05f)
val preferencesManager = globalClass.preferencesManager
val coroutineScope = rememberCoroutineScope()
var isRefreshing by remember { mutableStateOf(false) }
- val columnCount = preferencesManager.displayPrefs.fileListColumnCount
-
Box(Modifier.weight(1f)) {
if (tab.activeFolderContent.isEmpty() && !tab.isLoading) {
- Box(
- Modifier
- .fillMaxSize()
- .background(colorScheme.surface.copy(alpha = 0.4f)),
- contentAlignment = Alignment.Center
+ Column(
+ Modifier.align(Alignment.Center),
+ horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier
.fillMaxWidth()
.alpha(0.4f),
- text = stringResource(R.string.empty),
+ text = stringResource(
+ when {
+ !tab.activeFolder.canRead -> R.string.cant_access_content
+ else -> R.string.empty
+ }
+ ),
fontSize = 24.sp,
textAlign = TextAlign.Center
)
+ if (tab.activeFolder.canRead && !preferencesManager.fileListPrefs.showHiddenFiles) {
+ Space(12.dp)
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .alpha(0.4f),
+ text = stringResource(R.string.empty_without_hidden_files),
+ fontSize = 16.sp,
+ textAlign = TextAlign.Center
+ )
+ }
}
}
- PullToRefreshBox(
- isRefreshing = isRefreshing,
- onRefresh = {
- isRefreshing = true
- tab.openFolder(tab.activeFolder, true, true)
- coroutineScope.launch {
- delay(100)
- isRefreshing = false
- }
- },
- modifier = Modifier.fillMaxSize()
+ if (preferencesManager.behaviorPrefs.disablePullDownToRefresh) {
+ FilesListGrid(tab)
+ } else {
+ PullToRefreshBox(
+ isRefreshing = isRefreshing,
+ onRefresh = {
+ isRefreshing = true
+ tab.openFolder(tab.activeFolder, true, true)
+ coroutineScope.launch {
+ delay(100)
+ isRefreshing = false
+ }
+ },
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ FilesListGrid(tab)
+ }
+ }
+
+
+ androidx.compose.animation.AnimatedVisibility(
+ modifier = Modifier.fillMaxSize(),
+ visible = tab.isLoading
) {
- LazyVerticalGrid(
- columns = if (columnCount > 0)
- GridCells.Fixed(columnCount) else GridCells.Adaptive(300.dp),
- modifier = Modifier
- .fillMaxSize(),
- state = tab.activeListState
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(colorScheme.surface.copy(alpha = 0.4f))
+ .clickable(
+ interactionSource = null,
+ indication = null,
+ onClick = { }
+ ),
+ contentAlignment = Alignment.Center
) {
- itemsIndexed(
- tab.activeFolderContent,
- key = { index, item -> item.uid }
- ) { index, item ->
- val currentItemPath = item.path
- val itemDetailsCoroutine = rememberCoroutineScope()
- val isAlreadySelected = tab.selectedFiles.containsKey(currentItemPath)
+ CircularProgressIndicator()
+ }
+ }
+ }
+}
+
+@Composable
+fun FilesListGrid(tab: FilesTab) {
+ val context = LocalContext.current
+ val preferencesManager = globalClass.preferencesManager
+ val documentHolderSelectionHighlightColor = colorScheme.surfaceContainerHigh.copy(alpha = 1f)
+ val documentHolderHighlightColor = colorScheme.primary.copy(alpha = 0.05f)
+ val columnCount = preferencesManager.fileListPrefs.columnCount
+
+ LazyVerticalGrid(
+ columns = if (columnCount > 0)
+ GridCells.Fixed(columnCount) else GridCells.Adaptive(300.dp),
+ modifier = Modifier
+ .fillMaxSize(),
+ state = tab.activeListState
+ ) {
+ itemsIndexed(
+ tab.activeFolderContent,
+ key = { index, item -> item.uid }
+ ) { index, item ->
+ val currentItemPath = item.uniquePath
+ val itemDetailsCoroutine = rememberCoroutineScope()
+ val isAlreadySelected = tab.selectedFiles.containsKey(currentItemPath)
+
+ fun toggleSelection() {
+ if (tab.selectedFiles.containsKey(currentItemPath)) {
+ tab.selectedFiles.remove(currentItemPath)
+ tab.lastSelectedFileIndex = -1
+ } else {
+ tab.selectedFiles[currentItemPath] = item
+ tab.lastSelectedFileIndex = index
+ }
+ }
- fun toggleSelection() {
- if (tab.selectedFiles.containsKey(currentItemPath)) {
- tab.selectedFiles.remove(currentItemPath)
- tab.lastSelectedFileIndex = -1
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .background(
+ color = if (isAlreadySelected) {
+ documentHolderSelectionHighlightColor
+ } else if (tab.highlightedFiles.contains(currentItemPath)) {
+ documentHolderHighlightColor
} else {
- tab.selectedFiles[currentItemPath] = item
- tab.lastSelectedFileIndex = index
+ Color.Unspecified
}
- }
-
- Column(
- Modifier
- .fillMaxWidth()
- .background(
- color = if (isAlreadySelected) {
- documentHolderSelectionHighlightColor
- } else if (tab.highlightedFiles.contains(currentItemPath)) {
- documentHolderHighlightColor
+ )
+ .combinedClickable(
+ onClick = {
+ if (tab.selectedFiles.isNotEmpty()) {
+ toggleSelection()
+ tab.quickReloadFiles()
+ } else {
+ if (item.isFile()) {
+ tab.openFile(context, item)
} else {
- Color.Unspecified
+ tab.openFolder(item, false)
}
- )
- .combinedClickable(
- onClick = {
- if (tab.selectedFiles.isNotEmpty()) {
- toggleSelection()
- tab.quickReloadFiles()
- } else {
- if (item.isFile) {
- tab.openFile(context, item)
- } else {
- tab.openFolder(item, false)
- }
- }
- },
- onLongClick = {
- val isFirstSelection = tab.selectedFiles.isEmpty()
- val isNewSelection = !isAlreadySelected
+ }
+ },
+ onLongClick = {
+ val isFirstSelection = tab.selectedFiles.isEmpty()
+ val isNewSelection = !isAlreadySelected
- tab.selectedFiles[currentItemPath] = item
+ tab.selectedFiles[currentItemPath] = item
- if ((isFirstSelection && preferencesManager.generalPrefs.showFileOptionMenuOnLongClick) || !isNewSelection)
- tab.fileOptionsDialog.show(item)
+ if ((isFirstSelection && preferencesManager.behaviorPrefs.showFileOptionMenuOnLongClick)
+ || !isNewSelection
+ ) {
+ tab.fileOptionsDialog.show(item)
+ }
- if (isNewSelection) {
- if (tab.lastSelectedFileIndex >= 0) {
- if (tab.lastSelectedFileIndex > index) {
- for (i in tab.lastSelectedFileIndex downTo index) {
- tab.selectedFiles[tab.activeFolderContent[i].path] =
- tab.activeFolderContent[i]
- }
- } else {
- for (i in tab.lastSelectedFileIndex..index) {
- tab.selectedFiles[tab.activeFolderContent[i].path] =
- tab.activeFolderContent[i]
- }
- }
+ if (isNewSelection) {
+ if (tab.lastSelectedFileIndex >= 0) {
+ if (tab.lastSelectedFileIndex > index) {
+ for (i in tab.lastSelectedFileIndex downTo index) {
+ tab.selectedFiles[tab.activeFolderContent[i].uniquePath] =
+ tab.activeFolderContent[i]
+ }
+ } else {
+ for (i in tab.lastSelectedFileIndex..index) {
+ tab.selectedFiles[tab.activeFolderContent[i].uniquePath] =
+ tab.activeFolderContent[i]
}
}
- tab.lastSelectedFileIndex = index
-
- tab.quickReloadFiles()
}
- )
- ) {
- Space(
- size = when (preferencesManager.displayPrefs.fileListSize) {
- FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp
- else -> 4.dp
}
- )
+ tab.lastSelectedFileIndex = index
+ tab.quickReloadFiles()
+ }
+ )
+ ) {
+ Space(size = getFileListSpace().dp)
- Row(
- Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically
+ Row(
+ Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Isolate {
+ val iconSize = getFileListIconSize()
+
+ Box(
+ modifier = Modifier.size(iconSize.dp),
) {
- Isolate {
- val iconSize =
- when (preferencesManager.displayPrefs.fileListSize) {
- FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.IconSize.SMALL.dp
- FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.IconSize.MEDIUM.dp
- FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.IconSize.LARGE.dp
- else -> FilesTabFileListSizeMap.IconSize.EXTRA_LARGE.dp
- }
+ if (item.isFile()) {
+ AsyncImage(
+ modifier = Modifier
+ .size(iconSize.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .clickable {
+ toggleSelection()
+ tab.quickReloadFiles()
+ },
+ model = ImageRequest.Builder(globalClass).data(item)
+ .build(),
+ filterQuality = FilterQuality.Low,
+ error = painterResource(id = item.iconPlaceholder),
+ contentScale = ContentScale.Fit,
+ alpha = if (item.isHidden()) 0.4f else 1f,
+ contentDescription = null
+ )
+ } else {
+ Icon(
+ modifier = Modifier
+ .size(iconSize.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .clickable {
+ toggleSelection()
+ tab.quickReloadFiles()
+ }
+ .alpha(if (item.isHidden()) 0.4f else 1f),
+ imageVector = Icons.Rounded.Folder,
+ contentDescription = null
+ )
+ }
- if (item.isFile) {
- AsyncImage(
- modifier = Modifier
- .size(iconSize)
- .clip(RoundedCornerShape(4.dp))
- .clickable {
- toggleSelection()
- tab.quickReloadFiles()
- },
- model = ImageRequest.Builder(globalClass).data(item)
- .build(),
- filterQuality = FilterQuality.Low,
- error = painterResource(id = item.getFileIconResource()),
- contentScale = ContentScale.Fit,
- alpha = if (item.isHidden) 0.4f else 1f,
- contentDescription = null
- )
- } else {
- Icon(
- modifier = Modifier
- .size(iconSize)
- .clip(RoundedCornerShape(4.dp))
- .clickable {
- toggleSelection()
- tab.quickReloadFiles()
- }
- .alpha(if (item.isHidden) 0.4f else 1f),
- imageVector = Icons.Rounded.Folder,
- contentDescription = null
- )
- }
+ if (!item.canRead) {
+ Icon(
+ modifier = Modifier
+ .size(18.dp)
+ .align(Alignment.Center)
+ .alpha(if (item.isHidden()) 0.4f else 1f),
+ imageVector = Icons.Rounded.Lock,
+ tint = Color.Red,
+ contentDescription = null
+ )
}
+ }
+ }
- Space(size = 8.dp)
+ Space(size = 8.dp)
- Column(Modifier.weight(1f)) {
- val fontSize =
- when (preferencesManager.displayPrefs.fileListSize) {
- FilesTabFileListSize.SMALL.ordinal -> FilesTabFileListSizeMap.FontSize.SMALL
- FilesTabFileListSize.MEDIUM.ordinal -> FilesTabFileListSizeMap.FontSize.MEDIUM
- FilesTabFileListSize.LARGE.ordinal -> FilesTabFileListSizeMap.FontSize.LARGE
- else -> FilesTabFileListSizeMap.FontSize.EXTRA_LARGE
- }
+ Column(Modifier.weight(1f)) {
+ val fontSize = getFileListFontSize()
- Text(
- text = item.getName(),
- fontSize = fontSize.sp,
- maxLines = 1,
- lineHeight = (fontSize + 2).sp,
- overflow = TextOverflow.Ellipsis,
- color = if (tab.highlightedFiles.contains(currentItemPath)) {
- colorScheme.primary
- } else {
- Color.Unspecified
- }
- )
- Isolate {
- var details by remember(
- key1 = currentItemPath,
- key2 = item.lastModified
- ) { mutableStateOf(item.formattedDetailsCache) }
+ Text(
+ text = item.displayName,
+ fontSize = fontSize.sp,
+ maxLines = 1,
+ lineHeight = (fontSize + 2).sp,
+ overflow = TextOverflow.Ellipsis,
+ color = if (tab.highlightedFiles.contains(currentItemPath)) {
+ colorScheme.primary
+ } else {
+ Color.Unspecified
+ }
+ )
+ Isolate {
+ var details by remember(
+ key1 = currentItemPath,
+ key2 = item.lastModified
+ ) { mutableStateOf(item.details) }
- LaunchedEffect(
- key1 = currentItemPath,
- key2 = item.lastModified
- ) {
- if (details.isEmpty()) {
- itemDetailsCoroutine.launch(Dispatchers.IO) {
- val det = item.getFormattedDetails(
- true,
- preferencesManager.displayPrefs.showFolderContentCount
- )
- withContext(Dispatchers.Main) { details = det }
- }
- }
+ LaunchedEffect(
+ key1 = currentItemPath,
+ key2 = item.lastModified
+ ) {
+ if (details.isEmpty()) {
+ itemDetailsCoroutine.launch(Dispatchers.IO) {
+ val det = item.details
+ withContext(Dispatchers.Main) { details = det }
}
+ }
+ }
- Text(
- modifier = Modifier.alpha(0.7f),
- text = details,
- fontSize = (fontSize - 4).sp,
- maxLines = 1,
- lineHeight = (fontSize + 2).sp,
- overflow = TextOverflow.Ellipsis,
- color = if (tab.highlightedFiles.contains(
- currentItemPath
- )
- ) {
- colorScheme.primary
- } else {
- Color.Unspecified
- }
+ Text(
+ modifier = Modifier.alpha(0.7f),
+ text = details,
+ fontSize = (fontSize - 4).sp,
+ maxLines = 1,
+ lineHeight = (fontSize + 2).sp,
+ overflow = TextOverflow.Ellipsis,
+ color = if (tab.highlightedFiles.contains(
+ currentItemPath
)
+ ) {
+ colorScheme.primary
+ } else {
+ Color.Unspecified
}
- }
+ )
}
-
- Space(
- size = when (preferencesManager.displayPrefs.fileListSize) {
- FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8.dp
- else -> 4.dp
- }
- )
-
- HorizontalDivider(
- modifier = Modifier.padding(start = 56.dp),
- thickness = 0.5.dp
- )
}
}
- }
- }
- androidx.compose.animation.AnimatedVisibility(
- modifier = Modifier.fillMaxSize(),
- visible = tab.isLoading
- ) {
- Box(
- Modifier
- .fillMaxSize()
- .background(colorScheme.surface.copy(alpha = 0.4f))
- .clickable(
- interactionSource = null,
- indication = null,
- onClick = { }
- ),
- contentAlignment = Alignment.Center
- ) {
- CircularProgressIndicator()
+ Space(size = getFileListSpace().dp)
+
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ thickness = 0.5.dp
+ )
}
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt
index 379b2352..349f3a72 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FilesTabContentView.kt
@@ -6,6 +6,19 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.ApkPreviewDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.BookmarksDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.CreateNewFileDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.DeleteConfirmationDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FileCompressionDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FileOptionsMenuDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.FilePropertiesDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.OpenWithAppListDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.RenameDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.SearchDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.TaskConflictDialog
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.TaskPanel
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog.TaskRunningDialog
@Composable
fun ColumnScope.FilesTabContentView(tab: FilesTab) {
@@ -14,14 +27,15 @@ fun ColumnScope.FilesTabContentView(tab: FilesTab) {
BookmarksDialog(tab)
SearchDialog(tab)
TaskPanel(tab)
- TaskDialog(tab)
- RenameFileDialog(tab)
DeleteConfirmationDialog(tab)
CreateNewFileDialog(tab)
+ RenameDialog(tab)
FileCompressionDialog(tab)
FileOptionsMenuDialog(tab)
FilePropertiesDialog(tab)
- PathListRow(tab)
+ TaskRunningDialog()
+ TaskConflictDialog()
+ PathHistoryRow(tab)
HorizontalDivider(modifier = Modifier, thickness = 1.dp)
FilesList(tab)
BottomOptionsBar(tab)
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathListRow.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathHistoryRow.kt
similarity index 90%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathListRow.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathHistoryRow.kt
index d825f0b8..8fce181f 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathListRow.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/PathHistoryRow.kt
@@ -33,10 +33,11 @@ import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.orIf
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
import kotlinx.coroutines.launch
+import java.io.File
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun PathListRow(tab: FilesTab) {
+fun PathHistoryRow(tab: FilesTab) {
val highlightedPathListItemColor = MaterialTheme.colorScheme.primary
Row(
@@ -60,7 +61,7 @@ fun PathListRow(tab: FilesTab) {
Modifier.weight(1f),
tab.currentPathSegmentsListState,
) {
- itemsIndexed(tab.currentPathSegments, key = { _, it -> it.path }) { index, item ->
+ itemsIndexed(tab.currentPathSegments, key = { _, it -> it.uniquePath }) { index, item ->
val isHighlighted = index == tab.currentPathSegments.size - 1
Row(
@@ -85,9 +86,12 @@ fun PathListRow(tab: FilesTab) {
)
.padding(8.dp)
.alpha(0.8f),
- text = item.getName()
+ text = item.displayName
.orIf(stringResource(id = R.string.internal_storage)) {
- item.path == Environment.getExternalStorageDirectory().absolutePath
+ item.uniquePath == Environment.getExternalStorageDirectory().absolutePath
+ }
+ .orIf(stringResource(id = R.string.root)) {
+ item.uniquePath == File.separator
},
fontSize = 14.sp,
fontWeight = if (isHighlighted) FontWeight.Medium else FontWeight.Normal,
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/RenameFileDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/RenameFileDialog.kt
deleted file mode 100644
index 736c4a68..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/RenameFileDialog.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.isValidAsFileName
-import com.raival.compose.file.explorer.common.ui.InputDialog
-import com.raival.compose.file.explorer.common.ui.InputDialogButton
-import com.raival.compose.file.explorer.common.ui.InputDialogInput
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-
-@Composable
-fun RenameFileDialog(tab: FilesTab) {
- if (tab.renameDialog.showRenameFileDialog && tab.selectedFiles.isNotEmpty() && tab.renameDialog.targetFile != null) {
- val targetFile = tab.renameDialog.targetFile!!
-
- InputDialog(
- title = stringResource(R.string.rename),
- inputs = arrayListOf(
- InputDialogInput(
- label = stringResource(R.string.name),
- content = targetFile.getName()
- ) {
- if (!it.isValidAsFileName()) {
- globalClass.getString(R.string.invalid_file_name)
- } else {
- null
- }
- }
- ),
- buttons = arrayListOf(
- InputDialogButton(globalClass.getString(R.string.rename)) { inputs ->
- val input = inputs[0]
- if (input.content.isValidAsFileName()) {
- val similarFile = tab.activeFolder.findFile(input.content)
- if (similarFile == null) {
- targetFile.renameTo(input.content)
- with(tab) {
- highlightedFiles.apply {
- clear()
- add(targetFile.path)
- }
- unselectAllFiles(false)
- reloadFiles()
- renameDialog.hide()
- }
- } else {
- globalClass.showMsg(R.string.similar_file_exists)
- }
- } else {
- globalClass.showMsg(R.string.invalid_file_name)
- }
- },
- InputDialogButton(stringResource(R.string.cancel)) {
- tab.renameDialog.hide()
- }
- )
- ) {
- tab.renameDialog.hide()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskPanel.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskPanel.kt
deleted file mode 100644
index 37f2aee5..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskPanel.kt
+++ /dev/null
@@ -1,164 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Column
-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.material.ExperimentalMaterialApi
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Delete
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.kevinnzou.compose.swipebox.SwipeBox
-import com.kevinnzou.compose.swipebox.SwipeDirection
-import com.kevinnzou.compose.swipebox.widget.SwipeIcon
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-
-@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
-@Composable
-fun TaskPanel(tab: FilesTab) {
- if (tab.showTasksPanel) {
- BottomSheetDialog(
- onDismissRequest = { tab.showTasksPanel = false }
- ) {
- Text(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .fillMaxWidth(),
- text = stringResource(R.string.tasks),
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center
- )
-
- val coroutineScope = rememberCoroutineScope()
- LazyColumn(Modifier.animateContentSize()) {
- items(globalClass.filesTabManager.filesTabTasks, key = { it.id }) { task ->
- SwipeBox(
- modifier = Modifier.fillMaxWidth(),
- swipeDirection = SwipeDirection.EndToStart,
- endContentWidth = 60.dp,
- endContent = { swipeableState, endSwipeProgress ->
- SwipeIcon(
- imageVector = Icons.Outlined.Delete,
- contentDescription = stringResource(R.string.delete),
- tint = Color.White,
- background = Color(0xFFFA1E32),
- weight = 1f,
- iconSize = 20.dp
- ) {
- globalClass.filesTabManager.filesTabTasks.removeIf { it.id == task.id }
- coroutineScope.launch {
- swipeableState.animateTo(0)
- }
- }
- }
- ) { _, _, _ ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .combinedClickable(
- onClick = {
- if (!tab.canRunTasks()) {
- globalClass.showMsg(globalClass.getString(R.string.can_not_run_tasks))
- return@combinedClickable
- }
-
- if (!task.isValidSourceFiles()) {
- globalClass.showMsg(R.string.invalid_task)
- return@combinedClickable
- }
-
- var copyToExistingZipFile = false
-
- if (task is CompressTask) {
- if (tab.selectedFiles.size == 1) {
- val selectedFile = tab.selectedFiles.values.first()
- if (selectedFile.isArchive) {
- CoroutineScope(Dispatchers.IO).launch {
- task.execute(
- selectedFile,
- tab.taskCallback
- )
- }
- copyToExistingZipFile = true
- }
- }
- if (!copyToExistingZipFile) {
- tab.compressDialog.show(task)
- }
- } else {
- CoroutineScope(Dispatchers.IO).launch {
- task.execute(
- tab.activeFolder,
- tab.taskCallback
- )
- }
- }
- }
- )
- .padding(horizontal = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- modifier = Modifier.padding(12.dp),
- imageVector = task.getIcon(),
- contentDescription = null
- )
-
- Column(
- modifier = Modifier.padding(vertical = 12.dp)
- ) {
- Text(
- text = task.getTitle(),
- fontSize = 14.sp
- )
- Text(
- modifier = Modifier.alpha(0.7f),
- text = task.getSubtitle(),
- fontSize = 12.sp
- )
- }
- }
- }
- }
- }
-
- AnimatedVisibility(visible = globalClass.filesTabManager.filesTabTasks.isEmpty()) {
- Text(
- modifier = Modifier
- .padding(vertical = 60.dp)
- .fillMaxWidth()
- .alpha(0.4f),
- text = stringResource(R.string.empty),
- fontSize = 24.sp,
- textAlign = TextAlign.Center
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskProgressDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskProgressDialog.kt
deleted file mode 100644
index 40538aa3..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/TaskProgressDialog.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.window.Dialog
-import androidx.compose.ui.window.DialogProperties
-import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.common.ui.block
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-
-@Composable
-fun TaskDialog(tab: FilesTab) {
- if (tab.taskDialog.showTaskDialog) {
- val taskDialog = tab.taskDialog
- Dialog(
- onDismissRequest = { tab.taskDialog.showTaskDialog = false },
- properties = DialogProperties(
- dismissOnBackPress = false,
- dismissOnClickOutside = false
- )
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .block()
- .padding(16.dp)
- ) {
- Text(
- modifier = Modifier.fillMaxWidth(),
- text = taskDialog.taskDialogTitle,
- fontSize = 18.sp,
- textAlign = TextAlign.Center
- )
-
- Space(size = 12.dp)
-
- Text(
- modifier = Modifier
- .alpha(0.75f)
- .fillMaxWidth(),
- text = taskDialog.taskDialogSubtitle,
- fontSize = 16.sp
- )
-
- if (taskDialog.showTaskDialogProgressbar) {
- Space(size = 8.dp)
- if (taskDialog.taskDialogProgress < 0) {
- LinearProgressIndicator(
- modifier = Modifier.fillMaxWidth()
- )
- } else {
- LinearProgressIndicator(
- progress = {
- taskDialog.taskDialogProgress
- },
- modifier = Modifier.fillMaxWidth(),
- )
- }
- }
-
- Space(size = 4.dp)
-
- Text(
- modifier = Modifier
- .alpha(0.5f)
- .fillMaxWidth(),
- text = taskDialog.taskDialogInfo,
- fontSize = 10.sp
- )
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/ApkPreviewDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/ApkPreviewDialog.kt
similarity index 55%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/ApkPreviewDialog.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/ApkPreviewDialog.kt
index 2987971d..63e00728 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/ApkPreviewDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/ApkPreviewDialog.kt
@@ -1,4 +1,4 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
import android.graphics.drawable.Drawable
import android.os.Build
@@ -39,17 +39,10 @@ import com.raival.compose.file.explorer.common.extension.toFormattedSize
import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.MergeHandler
-import com.raival.compose.file.explorer.screen.preferences.PreferencesManager
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
@Composable
fun ApkPreviewDialog(tab: FilesTab) {
val apkDialog = tab.apkDialog
- val isApksArchive: Boolean = apkDialog.ApksArchive
if (tab.apkDialog.showApkDialog && apkDialog.apkFile != null) {
val context = LocalContext.current
@@ -59,41 +52,38 @@ fun ApkPreviewDialog(tab: FilesTab) {
val details = remember { mutableStateListOf>() }
var appName by remember { mutableStateOf(emptyString) }
- val doSign = PreferencesManager.GeneralPrefs.signApk
+ val packageManager = globalClass.packageManager
+ val apkInfo =
+ remember { mutableStateOf(packageManager.getPackageArchiveInfo(apkFile.uniquePath, 0)) }
- if (!isApksArchive) {
- val packageManager = globalClass.packageManager
- val apkInfo =
- remember { mutableStateOf(packageManager.getPackageArchiveInfo(apkFile.path, 0)) }
+ LaunchedEffect(Unit) {
+ apkInfo.value?.let { info ->
+ info.applicationInfo?.sourceDir = apkFile.uniquePath
+ info.applicationInfo?.publicSourceDir = apkFile.uniquePath
- LaunchedEffect(Unit) {
- apkInfo.value?.let { info ->
- info.applicationInfo?.sourceDir = apkFile.path
- info.applicationInfo?.publicSourceDir = apkFile.path
+ icon = info.applicationInfo?.loadIcon(packageManager)
+ appName = info.applicationInfo?.loadLabel(packageManager).toString()
- icon = info.applicationInfo?.loadIcon(packageManager)
- appName = info.applicationInfo?.loadLabel(packageManager).toString()
-
- details.add(
- Pair(
- globalClass.getString(R.string.package_name),
- info.packageName
- )
- )
- info.versionName?.let {
- details.add(Pair(globalClass.getString(R.string.version_name), it))
- }
- details.add(
- Pair(
- globalClass.getString(R.string.version_code),
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode.toString() else info.versionCode.toString()
- )
+ details.add(
+ Pair(
+ globalClass.getString(R.string.package_name),
+ info.packageName
)
+ )
+ info.versionName?.let {
+ details.add(Pair(globalClass.getString(R.string.version_name), it))
}
+ details.add(
+ Pair(
+ globalClass.getString(R.string.version_code),
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode.toString() else info.versionCode.toString()
+ )
+ )
}
}
+
details.add(
- Pair(globalClass.getString(R.string.size), apkFile.fileSize.toFormattedSize())
+ Pair(globalClass.getString(R.string.size), apkFile.size.toFormattedSize())
)
BottomSheetDialog(onDismissRequest = { apkDialog.hide() }) {
@@ -148,56 +138,26 @@ fun ApkPreviewDialog(tab: FilesTab) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onClick = {
apkDialog.hide()
- apkFile.openFile(
+ apkFile.open(
context = context,
anonymous = false,
- skipSupportedExtensions = true,
+ skipSupportedExtensions = false,
customMimeType = "application/zip"
)
}) {
Text(text = stringResource(R.string.explore))
}
- if (isApksArchive) {
- TextButton(onClick = {
- apkDialog.hide()
- val mergeHandler = MergeHandler(context)
- mergeHandler.mergeApks(
- tab, apkFile, doSign,
- onSuccess = {
- CoroutineScope(Dispatchers.Main).launch {
- tab.taskDialog.taskDialogInfo =
- context.getString(R.string.merge_successful)
- tab.taskDialog.taskDialogSubtitle =
- context.getString(R.string.merge_completed)
- tab.taskDialog.taskDialogProgress = 1f
- delay(500)
- tab.taskDialog.showTaskDialog = false
- tab.reloadFiles()
- }
- },
- onError = { errorMessage ->
- CoroutineScope(Dispatchers.Main).launch {
- tab.taskDialog.taskDialogInfo = errorMessage
- tab.taskDialog.taskDialogSubtitle =
- context.getString(R.string.failed)
- }
- }
- )
- }) {
- Text(text = stringResource(R.string.merge))
- }
- } else {
- TextButton(onClick = {
- apkDialog.hide()
- apkFile.openFile(
- context,
- anonymous = false,
- skipSupportedExtensions = true
- )
- }) {
- Text(text = stringResource(R.string.install))
- }
+ TextButton(onClick = {
+ apkDialog.hide()
+ apkFile.open(
+ context,
+ anonymous = false,
+ skipSupportedExtensions = true,
+ customMimeType = null
+ )
+ }) {
+ Text(text = stringResource(R.string.install))
}
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/BookmarksDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/BookmarksDialog.kt
new file mode 100644
index 00000000..36623172
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/BookmarksDialog.kt
@@ -0,0 +1,184 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.rounded.Bookmark
+import androidx.compose.material.icons.rounded.BookmarkBorder
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+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.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import com.kevinnzou.compose.swipebox.SwipeBox
+import com.kevinnzou.compose.swipebox.SwipeDirection
+import com.kevinnzou.compose.swipebox.widget.SwipeIcon
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.trimToLastTwoSegments
+import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
+import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.FileItemRow
+import java.io.File
+
+@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
+@Composable
+fun BookmarksDialog(tab: FilesTab) {
+ if (tab.showBookmarkDialog) {
+ val context = LocalContext.current
+ val bookmarks = remember {
+ mutableStateListOf()
+ }
+
+ LaunchedEffect(Unit) {
+ val originalList = globalClass.filesTabManager.bookmarks
+ bookmarks.addAll(
+ originalList.map { LocalFileHolder(File(it)) }.filter { it.isValid() }
+ )
+
+ if (bookmarks.size != originalList.size) {
+ globalClass.filesTabManager.bookmarks = bookmarks.map { it.uniquePath }.toSet()
+ }
+ }
+
+ BottomSheetDialog(
+ onDismissRequest = { tab.showBookmarkDialog = false }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Bookmark,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ Space(8.dp)
+ Text(
+ text = stringResource(R.string.bookmarks),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ Space(size = 8.dp)
+
+ AnimatedVisibility(globalClass.filesTabManager.bookmarks.isNotEmpty()) {
+ LazyColumn(Modifier.animateContentSize()) {
+ itemsIndexed(
+ items = bookmarks,
+ key = { index, item -> item.uniquePath }
+ ) { index, item ->
+ SwipeBox(
+ modifier = Modifier.fillMaxWidth(),
+ swipeDirection = SwipeDirection.EndToStart,
+ endContentWidth = 60.dp,
+ endContent = { swipeableState, endSwipeProgress ->
+ SwipeIcon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.delete),
+ tint = Color.White,
+ background = Color(0xFFFA1E32),
+ weight = 1f,
+ iconSize = 20.dp
+ ) {
+ globalClass.filesTabManager.bookmarks -= item.uniquePath
+ }
+ }
+ ) { _, _, _ ->
+ Modifier
+ .fillMaxWidth()
+ Column(
+ Modifier
+ .animateItem()
+ .combinedClickable(
+ onClick = {
+ if (item.isFile()) {
+ tab.openFile(context, item)
+ } else {
+ tab.requestNewTab(FilesTab(item))
+ }
+ tab.showBookmarkDialog = false
+ },
+ onLongClick = { }
+ )
+ ) {
+ Space(size = 4.dp)
+ FileItemRow(
+ item = item,
+ fileDetails = item.uniquePath.trimToLastTwoSegments()
+ )
+ Space(size = 4.dp)
+ HorizontalDivider(
+ modifier = Modifier.padding(start = 56.dp),
+ thickness = 0.5.dp
+ )
+ }
+ }
+ }
+ }
+ }
+
+ AnimatedVisibility(globalClass.filesTabManager.bookmarks.isEmpty()) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 64.dp, horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.BookmarkBorder,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.size(48.dp)
+ )
+ Space(16.dp)
+ Text(
+ text = stringResource(R.string.empty),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ Space(8.dp)
+ Text(
+ text = stringResource(R.string.no_bookmarks_available),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/CreateNewFileDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/CreateNewFileDialog.kt
new file mode 100644
index 00000000..f47d67f5
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/CreateNewFileDialog.kt
@@ -0,0 +1,201 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.isValidAsFileName
+import com.raival.compose.file.explorer.common.ui.CheckableText
+import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun CreateNewFileDialog(tab: FilesTab) {
+ if (tab.showCreateNewFileDialog) {
+ var isOpenFileDirectly by remember { mutableStateOf(false) }
+ val listContent by remember(tab.activeFolderContent) {
+ mutableStateOf(tab.activeFolderContent.map { it.displayName }.toTypedArray())
+ }
+ var newNameInput by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf("") }
+
+ LaunchedEffect(newNameInput) {
+ error = if (newNameInput.isBlank()) {
+ emptyString
+ } else if (!newNameInput.isValidAsFileName()) {
+ globalClass.getString(R.string.invalid_file_name)
+ } else if (listContent.contains(newNameInput)) {
+ globalClass.getString(R.string.similar_file_exists)
+ } else {
+ emptyString
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { tab.showCreateNewFileDialog = false },
+ ) {
+ Card(
+ shape = RoundedCornerShape(6.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.create_new),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = newNameInput,
+ onValueChange = {
+ newNameInput = it
+ },
+ label = { Text(text = stringResource(R.string.name)) },
+ singleLine = true,
+ shape = RoundedCornerShape(6.dp),
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ isError = error.isNotEmpty(),
+ supportingText = if (error.isNotEmpty()) {
+ { Text(error) }
+ } else null
+ )
+
+ CheckableText(
+ checked = isOpenFileDirectly,
+ onCheckedChange = { isOpenFileDirectly = it },
+ ) {
+ Text(stringResource(R.string.open_created_folder))
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ if (newNameInput.isValidAsFileName()) {
+ val similarFile = tab.activeFolder.findFile(newNameInput)
+ if (similarFile == null) {
+ tab.showCreateNewFileDialog = false
+ tab.isLoading = true
+ CoroutineScope(Dispatchers.IO).launch {
+ tab.activeFolder.createSubFile(newNameInput) { newFile ->
+ tab.isLoading = false
+ if (newFile == null) {
+ globalClass.showMsg(R.string.failed_to_create_file)
+ } else {
+ tab.onNewFileCreated(newFile)
+ }
+ }
+ }
+ } else {
+ globalClass.showMsg(R.string.similar_file_exists)
+ }
+ } else {
+ globalClass.showMsg(R.string.invalid_file_name)
+ }
+ },
+ enabled = error.isEmpty() && newNameInput.isNotBlank(),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.file),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ if (newNameInput.isValidAsFileName()) {
+ val similarFile = tab.activeFolder.findFile(newNameInput)
+ if (similarFile == null) {
+ tab.showCreateNewFileDialog = false
+ tab.isLoading = true
+ CoroutineScope(Dispatchers.IO).launch {
+ tab.activeFolder.createSubFolder(newNameInput) { newFile ->
+ tab.isLoading = false
+ if (newFile == null) {
+ globalClass.showMsg(R.string.failed_to_create_folder)
+ } else {
+ tab.onNewFileCreated(
+ newFile,
+ isOpenFileDirectly
+ )
+ }
+ }
+ }
+ } else {
+ globalClass.showMsg(R.string.similar_file_exists)
+ }
+ } else {
+ globalClass.showMsg(R.string.invalid_folder_name)
+ }
+ },
+ enabled = error.isEmpty() && newNameInput.isNotBlank(),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.folder),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DeleteConfirmationDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/DeleteConfirmationDialog.kt
similarity index 64%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DeleteConfirmationDialog.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/DeleteConfirmationDialog.kt
index 75b731e1..e58e47a7 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/DeleteConfirmationDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/DeleteConfirmationDialog.kt
@@ -1,4 +1,4 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
@@ -19,13 +19,21 @@ import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.ui.CheckableText
import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask
+import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTaskParameters
+import com.raival.compose.file.explorer.screen.main.tab.files.task.DeleteTask
+import com.raival.compose.file.explorer.screen.main.tab.files.task.DeleteTaskParameters
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
@Composable
fun DeleteConfirmationDialog(tab: FilesTab) {
if (tab.showConfirmDeleteDialog) {
val preferencesManager = globalClass.preferencesManager
+
var moveToRecycleBin by remember {
- mutableStateOf(preferencesManager.generalPrefs.moveToRecycleBin)
+ mutableStateOf(preferencesManager.fileOperationPrefs.moveToRecycleBin)
}
var showRememberChoice by remember {
mutableStateOf(false)
@@ -34,7 +42,7 @@ fun DeleteConfirmationDialog(tab: FilesTab) {
mutableStateOf(false)
}
- val targetFiles by remember(tab.id, tab.activeFolder.path) {
+ val targetFiles by remember(tab.id, tab.activeFolder.uniquePath) {
mutableStateOf(tab.selectedFiles.map { it.value }.toList())
}
@@ -47,12 +55,38 @@ fun DeleteConfirmationDialog(tab: FilesTab) {
confirmButton = {
TextButton(
onClick = {
+ val coroutineScope = CoroutineScope(Dispatchers.IO)
onDismissRequest()
tab.unselectAllFiles()
if (showRememberChoice && rememberChoice) {
- preferencesManager.generalPrefs.moveToRecycleBin = moveToRecycleBin
+ preferencesManager.fileOperationPrefs.moveToRecycleBin =
+ moveToRecycleBin
+ }
+ coroutineScope.launch {
+ if (!tab.showEmptyRecycleBin && moveToRecycleBin) {
+ globalClass.recycleBinDir.createSubFolder(
+ System.currentTimeMillis().toString()
+ ) { newDir ->
+ if (newDir != null) {
+ coroutineScope.launch {
+ globalClass.taskManager.addTaskAndRun(
+ CopyTask(targetFiles, true),
+ CopyTaskParameters(
+ newDir
+ )
+ )
+ }
+ } else {
+ globalClass.showMsg(globalClass.getString(R.string.unable_to_move_to_recycle_bin))
+ }
+ }
+ } else {
+ globalClass.taskManager.addTaskAndRun(
+ DeleteTask(targetFiles),
+ DeleteTaskParameters()
+ )
+ }
}
- tab.deleteFiles(targetFiles, tab.taskCallback, moveToRecycleBin)
}
) { Text(stringResource(R.string.confirm)) }
},
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileCompressionDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileCompressionDialog.kt
new file mode 100644
index 00000000..939c6ba5
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileCompressionDialog.kt
@@ -0,0 +1,171 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.isValidAsFileName
+import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTaskParameters
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.launch
+import java.io.File
+
+@Composable
+fun FileCompressionDialog(tab: FilesTab) {
+ if (tab.newZipFileDialog.show) {
+ val listContent by remember(tab.activeFolderContent) {
+ mutableStateOf(tab.activeFolderContent.map { it.displayName }.toTypedArray())
+ }
+
+ var newNameInput by remember { mutableStateOf("${tab.activeFolder.displayName}.zip") }
+ var error by remember { mutableStateOf("") }
+
+ LaunchedEffect(newNameInput) {
+ error = if (newNameInput.isBlank()) {
+ emptyString
+ } else if (!newNameInput.isValidAsFileName()) {
+ globalClass.getString(R.string.invalid_file_name)
+ } else if (listContent.contains(newNameInput)) {
+ globalClass.getString(R.string.similar_file_exists)
+ } else {
+ emptyString
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { tab.newZipFileDialog.hide() },
+ ) {
+ Card(
+ shape = RoundedCornerShape(6.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.create_archive),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = newNameInput,
+ onValueChange = {
+ newNameInput = it
+ },
+ label = { Text(text = stringResource(R.string.name)) },
+ singleLine = true,
+ shape = RoundedCornerShape(6.dp),
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ isError = error.isNotEmpty(),
+ supportingText = if (error.isNotEmpty()) {
+ { Text(error) }
+ } else null
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ tab.newZipFileDialog.hide()
+ },
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.cancel),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ if (newNameInput.isValidAsFileName() && !listContent.contains(
+ newNameInput
+ )
+ ) {
+ tab.newZipFileDialog.hide()
+ CoroutineScope(IO).launch {
+ globalClass.taskManager.runTask(
+ tab.newZipFileDialog.task!!.id,
+ CompressTaskParameters(
+ File(
+ (tab.activeFolder as LocalFileHolder).file,
+ newNameInput
+ ).absolutePath
+ )
+ )
+ }
+ } else {
+ globalClass.showMsg(R.string.invalid_file_name)
+ }
+ },
+ enabled = error.isEmpty() && newNameInput.isNotBlank(),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.create),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOptionsMenuDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt
similarity index 50%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOptionsMenuDialog.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt
index 356664c6..dcb30518 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileOptionsMenuDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileOptionsMenuDialog.kt
@@ -1,8 +1,11 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.ExitToApp
import androidx.compose.material.icons.automirrored.rounded.OpenInNew
import androidx.compose.material.icons.rounded.BookmarkAdd
import androidx.compose.material.icons.rounded.Compress
@@ -13,16 +16,21 @@ import androidx.compose.material.icons.rounded.FileCopy
import androidx.compose.material.icons.rounded.FormatColorText
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material.icons.rounded.Merge
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -32,31 +40,37 @@ import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.UpdateAction
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.apkBundleFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.task.ApksMergeTask
+import com.raival.compose.file.explorer.screen.main.tab.files.task.ApksMergeTaskParameters
import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask
import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask
-import com.raival.compose.file.explorer.screen.main.tab.files.task.DecompressTask
-import com.raival.compose.file.explorer.screen.main.tab.files.task.MoveTask
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.FileIcon
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRow
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
@Composable
fun FileOptionsMenuDialog(tab: FilesTab) {
if (tab.fileOptionsDialog.showFileOptionsDialog
&& tab.fileOptionsDialog.targetFile != null
- && tab.id == globalClass.mainActivityManager.tabs[globalClass.mainActivityManager.selectedTabIndex].id
) {
-
val context = LocalContext.current
val targetFiles by remember {
mutableStateOf(tab.selectedFiles.map { it.value }.toList())
}
- val targetDocumentHolder = tab.fileOptionsDialog.targetFile!!
+
+ val targetContentHolder = tab.fileOptionsDialog.targetFile!!
val selectedFilesCount = targetFiles.size
val isMultipleSelection = selectedFilesCount > 1
- val isSingleFile = !isMultipleSelection && targetDocumentHolder.isFile
- val isSingleFolder = !isMultipleSelection && targetDocumentHolder.isFolder
+ val isSingleFile = !isMultipleSelection && targetContentHolder.isFile()
+ val isSingleFolder = !isMultipleSelection && targetContentHolder.isFolder
var hasFolders = false
tab.selectedFiles.forEach {
@@ -72,18 +86,16 @@ fun FileOptionsMenuDialog(tab: FilesTab) {
if (selectedFilesCount > 1) {
"and %d more".format(selectedFilesCount - 1)
} else {
- targetDocumentHolder.getFormattedDetails(
- showFolderContentCount = globalClass.preferencesManager.displayPrefs.showFolderContentCount
- )
+ targetContentHolder.details
}
)
}
ItemRow(
- title = targetDocumentHolder.getName(),
+ title = targetContentHolder.displayName,
subtitle = details,
icon = {
- FileIcon(documentHolder = targetDocumentHolder)
+ FileIcon(contentHolder = targetContentHolder)
}
)
@@ -92,6 +104,7 @@ fun FileOptionsMenuDialog(tab: FilesTab) {
Space(size = 6.dp)
Row {
+ // Delete
IconButton(
modifier = Modifier.weight(1f),
onClick = {
@@ -106,71 +119,80 @@ fun FileOptionsMenuDialog(tab: FilesTab) {
)
}
+ // Cut
IconButton(
modifier = Modifier.weight(1f),
onClick = {
tab.hideDocumentOptionsMenu()
- tab.addNewTask(MoveTask(targetFiles).apply {
- postActions.add(
- tab.addNewAction(UpdateAction(due = false))
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.taskManager.addTask(
+ CopyTask(
+ targetFiles,
+ deleteSourceFiles = true
+ )
)
- })
+ }
tab.unselectAllFiles()
}
) {
Icon(imageVector = Icons.Rounded.ContentCut, contentDescription = null)
}
+ // Copy
IconButton(
modifier = Modifier.weight(1f),
onClick = {
tab.hideDocumentOptionsMenu()
- tab.addNewTask(CopyTask(targetFiles))
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.taskManager.addTask(
+ CopyTask(
+ targetFiles,
+ deleteSourceFiles = false
+ )
+ )
+ }
tab.unselectAllFiles()
}
) {
Icon(imageVector = Icons.Rounded.FileCopy, contentDescription = null)
}
- if (isSingleFile || isSingleFolder) {
- IconButton(
- modifier = Modifier.weight(1f),
- onClick = {
- tab.hideDocumentOptionsMenu()
- tab.renameDialog.show(targetDocumentHolder)
- }
- ) {
- Icon(
- imageVector = Icons.Rounded.FormatColorText,
- contentDescription = null
- )
+ // Rename
+ IconButton(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ tab.hideDocumentOptionsMenu()
+ tab.renameDialog.show(targetContentHolder)
}
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.FormatColorText,
+ contentDescription = null
+ )
}
- if (tab.activeFolder != StorageProvider.bookmarks) {
+
+ // Share
+ if (!hasFolders && targetContentHolder is LocalFileHolder) {
IconButton(
modifier = Modifier.weight(1f),
onClick = {
tab.hideDocumentOptionsMenu()
- globalClass.filesTabManager.bookmarks += targetFiles.map { it.path }
- .distinct()
- globalClass.showMsg(R.string.added_to_bookmarks)
- tab.unselectAllFiles()
+ tab.shareSelectedFiles(context)
}
) {
- Icon(imageVector = Icons.Rounded.BookmarkAdd, contentDescription = null)
+ Icon(imageVector = Icons.Rounded.Share, contentDescription = null)
}
}
- if (!hasFolders) {
- IconButton(
- modifier = Modifier.weight(1f),
- onClick = {
- tab.hideDocumentOptionsMenu()
- tab.share(context, targetDocumentHolder)
- }
- ) {
- Icon(imageVector = Icons.Rounded.Share, contentDescription = null)
+ // Properties
+ IconButton(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ tab.hideDocumentOptionsMenu()
+ tab.showFileProperties = true
}
+ ) {
+ Icon(imageVector = Icons.Rounded.Info, contentDescription = null)
}
}
@@ -183,58 +205,105 @@ fun FileOptionsMenuDialog(tab: FilesTab) {
stringResource(R.string.open_in_new_tab)
) {
tab.hideDocumentOptionsMenu()
- tab.requestNewTab(FilesTab(targetDocumentHolder))
+ tab.requestNewTab(FilesTab(targetContentHolder))
tab.unselectAllFiles()
}
}
- if (isSingleFile) {
+ if (isSingleFile && targetContentHolder is LocalFileHolder) {
FileOption(
Icons.AutoMirrored.Rounded.OpenInNew,
stringResource(R.string.open_with)
) {
tab.hideDocumentOptionsMenu()
- tab.openWithDialog.show(targetDocumentHolder)
+ tab.openWithDialog.show(targetContentHolder)
+ }
+ }
+
+ if (tab.activeFolder is LocalFileHolder ||
+ (tab.activeFolder is VirtualFileHolder && (tab.activeFolder as VirtualFileHolder).type != VirtualFileHolder.BOOKMARKS)
+ ) {
+ FileOption(Icons.Rounded.BookmarkAdd, stringResource(R.string.add_to_bookmarks)) {
+ tab.hideDocumentOptionsMenu()
+ globalClass.filesTabManager.bookmarks += targetFiles.map { it.uniquePath }
+ .distinct()
+ globalClass.showMsg(R.string.added_to_bookmarks)
+ tab.unselectAllFiles()
}
}
- if (isRequestPinShortcutSupported(context) && (isSingleFile || isSingleFolder)) {
+ if (isRequestPinShortcutSupported(context) && tab.activeFolder is LocalFileHolder && (isSingleFile || isSingleFolder)) {
FileOption(Icons.Rounded.Home, stringResource(R.string.add_to_home_screen)) {
tab.hideDocumentOptionsMenu()
- tab.addToHomeScreen(context, targetDocumentHolder)
+ tab.addToHomeScreen(context, targetContentHolder as LocalFileHolder)
tab.unselectAllFiles()
}
}
- if (isSingleFile) {
+ if (isSingleFile && targetContentHolder is LocalFileHolder) {
FileOption(Icons.Rounded.EditNote, stringResource(R.string.edit_with_text_editor)) {
tab.hideDocumentOptionsMenu()
- globalClass.textEditorManager.openTextEditor(targetDocumentHolder, context)
+ globalClass.textEditorManager.openTextEditor(targetContentHolder, context)
tab.unselectAllFiles()
}
- }
- FileOption(Icons.Rounded.Compress, stringResource(R.string.compress)) {
- tab.hideDocumentOptionsMenu()
- tab.addNewTask(CompressTask(targetFiles))
- tab.unselectAllFiles()
+ if (apkBundleFileType.contains(targetContentHolder.file.extension)) {
+ FileOption(Icons.Rounded.Merge, stringResource(R.string.convert_to_apk)) {
+ tab.hideDocumentOptionsMenu()
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.taskManager.addTaskAndRun(
+ ApksMergeTask(targetContentHolder),
+ ApksMergeTaskParameters(
+ globalClass.preferencesManager.fileOperationPrefs.signMergedApkBundleFiles
+ )
+ )
+ }
+ tab.unselectAllFiles()
+ }
+ }
}
- if (isSingleFile && !hasFolders && targetDocumentHolder.isArchive) {
- FileOption(
- Icons.AutoMirrored.Rounded.ExitToApp,
- stringResource(R.string.decompress)
- ) {
+ if (tab.activeFolder !is ZipFileHolder) {
+ FileOption(Icons.Rounded.Compress, stringResource(R.string.compress)) {
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.taskManager.addTask(
+ CompressTask(targetFiles)
+ )
+ }
tab.hideDocumentOptionsMenu()
- tab.addNewTask(DecompressTask(targetFiles))
tab.unselectAllFiles()
}
}
+ }
+ }
+}
- FileOption(Icons.Rounded.Info, stringResource(R.string.details)) {
- tab.hideDocumentOptionsMenu()
- tab.showFileProperties = true
+@Composable
+fun FileOption(
+ icon: ImageVector,
+ text: String,
+ highlight: Color = Color.Unspecified,
+ onClick: () -> Unit
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clickable {
+ onClick()
}
- }
+ .padding(vertical = 16.dp, horizontal = 16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ modifier = Modifier.size(21.dp),
+ imageVector = icon,
+ tint = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight,
+ contentDescription = null
+ )
+ Space(size = 12.dp)
+ Text(
+ text = text,
+ color = if (highlight == Color.Unspecified) MaterialTheme.colorScheme.onSurface else highlight
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FilePropertiesDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FilePropertiesDialog.kt
new file mode 100644
index 00000000..dd9ceba7
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FilePropertiesDialog.kt
@@ -0,0 +1,458 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Category
+import androidx.compose.material.icons.filled.DataUsage
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.DriveFileRenameOutline
+import androidx.compose.material.icons.filled.Fingerprint
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.FolderOpen
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Inventory
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Schedule
+import androidx.compose.material.icons.filled.Security
+import androidx.compose.material.icons.filled.SelectAll
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalClipboard
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.copyToClipboard
+import com.raival.compose.file.explorer.common.extension.showMsg
+import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.common.ui.block
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.CalculationProgress
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.ContentPropertiesProvider
+import com.raival.compose.file.explorer.screen.main.tab.files.provider.PropertiesState
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.StateFlow
+
+@Composable
+fun FilePropertiesDialog(tab: FilesTab) {
+ if (tab.showFileProperties) {
+ val selection = tab.selectedFiles.map { it.value }.toList()
+ val contentPropertiesProvider = remember { ContentPropertiesProvider(selection) }
+ val uiState by contentPropertiesProvider.uiState.collectAsState()
+
+ DisposableEffect(contentPropertiesProvider) {
+ onDispose {
+ contentPropertiesProvider.cleanup()
+ }
+ }
+
+ val title = when (uiState.details) {
+ is PropertiesState.SingleContentProperties -> stringResource(R.string.file_properties)
+ is PropertiesState.MultipleContentProperties -> stringResource(R.string.selection_properties)
+ else -> stringResource(R.string.loading_properties)
+ }
+
+ Dialog(
+ onDismissRequest = { tab.showFileProperties = false }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .block()
+ .padding(24.dp)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = when (uiState.details) {
+ is PropertiesState.SingleContentProperties -> if (selection.first().isFolder) Icons.Default.Folder else Icons.Default.Description
+ is PropertiesState.MultipleContentProperties -> Icons.Default.SelectAll
+ else -> Icons.Default.Info
+ },
+ contentDescription = null,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Text(text = title, style = MaterialTheme.typography.headlineSmall)
+ }
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 600.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ item {
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+ }
+
+ item {
+ AnimatedContent(
+ targetState = uiState.details,
+ transitionSpec = {
+ fadeIn(animationSpec = tween(300)) togetherWith
+ fadeOut(animationSpec = tween(300))
+ },
+ label = "content_transition"
+ ) { details ->
+ when (details) {
+ is PropertiesState.Loading -> {
+ LoadingContent()
+ }
+
+ is PropertiesState.SingleContentProperties -> {
+ SingleFileContent(details)
+ }
+
+ is PropertiesState.MultipleContentProperties -> {
+ MultipleFilesContent(details)
+ }
+ }
+ }
+ }
+ }
+
+ Space(16.dp)
+
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(6.dp),
+ onClick = { tab.showFileProperties = false }
+ ) {
+ Text(stringResource(R.string.close))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LoadingContent() {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp)
+ ) {
+ CircularProgressIndicator()
+ Space(16.dp)
+ Text(
+ text = stringResource(R.string.analyzing_files),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun SingleFileContent(details: PropertiesState.SingleContentProperties) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ // Basic properties
+ PropertySection(title = stringResource(R.string.general)) {
+ PropertyRow(
+ icon = Icons.Default.DriveFileRenameOutline,
+ label = stringResource(R.string.name),
+ value = details.name
+ )
+ PropertyRow(
+ icon = Icons.Default.FolderOpen,
+ label = stringResource(R.string.location),
+ value = details.path
+ )
+ PropertyRow(
+ icon = Icons.Default.Category,
+ label = stringResource(R.string.type),
+ value = details.type
+ )
+ PropertyRow(
+ icon = Icons.Default.DataUsage,
+ label = stringResource(R.string.size),
+ value = details.size
+ )
+ PropertyRow(
+ icon = Icons.Default.Schedule,
+ label = stringResource(R.string.modified),
+ value = details.lastModified
+ )
+ }
+
+ // System properties
+ PropertySection(title = stringResource(R.string.system)) {
+ PropertyRow(
+ icon = Icons.Default.Person,
+ label = stringResource(R.string.owner),
+ value = details.owner
+ )
+ PropertyRow(
+ icon = Icons.Default.Security,
+ label = stringResource(R.string.permissions),
+ value = details.permissions
+ )
+ }
+
+ // Computed properties
+ PropertySection(title = stringResource(R.string.analysis)) {
+ AsyncPropertyRow(
+ icon = Icons.Default.Inventory,
+ label = stringResource(R.string.contents),
+ valueFlow = details.contentCount,
+ progressFlow = details.contentProgress
+ )
+ AsyncPropertyRow(
+ icon = Icons.Default.Fingerprint,
+ label = stringResource(R.string.md5_checksum),
+ valueFlow = details.checksum,
+ progressFlow = details.checksumProgress
+ )
+ }
+ }
+}
+
+@Composable
+private fun MultipleFilesContent(details: PropertiesState.MultipleContentProperties) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ PropertySection(title = stringResource(R.string.selection_summary)) {
+ PropertyRow(
+ icon = Icons.Default.SelectAll,
+ label = stringResource(R.string.selected),
+ value = stringResource(R.string.items_count, details.selectedFileCount)
+ )
+ AsyncPropertyRow(
+ icon = Icons.Default.DataUsage,
+ label = stringResource(R.string.total_size),
+ valueFlow = details.totalSize,
+ progressFlow = details.sizeProgress
+ )
+ AsyncPropertyRow(
+ icon = Icons.Default.Inventory,
+ label = stringResource(R.string.total_contents),
+ valueFlow = details.totalFileCount,
+ progressFlow = details.countProgress
+ )
+ }
+ }
+}
+
+@Composable
+private fun PropertySection(
+ title: String,
+ content: @Composable () -> Unit
+) {
+ Column {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(bottom = 8.dp, start = 6.dp)
+ )
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerLow
+ ),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ content()
+ }
+ }
+ }
+}
+
+@Composable
+fun PropertyRow(
+ icon: ImageVector,
+ label: String,
+ value: String
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+ Space(12.dp)
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.width(100.dp),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ CopiableText(
+ text = value,
+ modifier = Modifier.weight(1f)
+ )
+ }
+}
+
+@Composable
+fun AsyncPropertyRow(
+ icon: ImageVector,
+ label: String,
+ valueFlow: StateFlow,
+ progressFlow: StateFlow
+) {
+ val value by valueFlow.collectAsState()
+ val progress by progressFlow.collectAsState()
+
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
+ Space(12.dp)
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.width(100.dp),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (progress.isCalculating) {
+ PulsingDot()
+ Space(8.dp)
+ }
+ CopiableText(
+ text = value,
+ color = if (progress.isCalculating)
+ MaterialTheme.colorScheme.onSurfaceVariant
+ else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PulsingDot() {
+ val infiniteTransition = rememberInfiniteTransition(label = "pulse")
+ val alpha by infiniteTransition.animateFloat(
+ initialValue = 0.3f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(1000, easing = LinearEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "alpha"
+ )
+
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .clip(RoundedCornerShape(4.dp))
+ .background(MaterialTheme.colorScheme.primary.copy(alpha = alpha))
+ )
+}
+
+@Composable
+fun CopiableText(
+ text: String,
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.onSurface
+) {
+ LocalContext.current
+ LocalClipboard.current
+ var showCopiedFeedback by remember { mutableStateOf(false) }
+
+ LaunchedEffect(showCopiedFeedback) {
+ if (showCopiedFeedback) {
+ delay(2000)
+ showCopiedFeedback = false
+ }
+ }
+
+ Box(modifier = modifier) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = color,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .fillMaxWidth()
+ .pointerInput(text) {
+ detectTapGestures(
+ onLongPress = {
+ text.copyToClipboard()
+ showCopiedFeedback = true
+ showMsg(globalClass.getString(R.string.copied_to_clipboard))
+ }
+ )
+ }
+ )
+
+ // Subtle visual feedback
+ if (showCopiedFeedback) {
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(
+ MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
+ RoundedCornerShape(4.dp)
+ )
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileSortingMenu.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileSortingMenuDialog.kt
similarity index 92%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileSortingMenu.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileSortingMenuDialog.kt
index 7ff3fbba..156318eb 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/FileSortingMenu.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/FileSortingMenuDialog.kt
@@ -1,4 +1,4 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -34,7 +34,7 @@ import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileSortingPr
import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod
@Composable
-fun FileSortingMenu(
+fun FileSortingMenuDialog(
tab: FilesTab,
reloadFiles: () -> Unit,
onDismissRequest: () -> Unit,
@@ -42,16 +42,16 @@ fun FileSortingMenu(
val prefs = globalClass.preferencesManager.filesSortingPrefs
val specificOptions = prefs.getSortingPrefsFor(tab.activeFolder)
- var applyForThisFileOnly by remember(tab.activeFolder.path) {
+ var applyForThisFileOnly by remember(tab.activeFolder.uniquePath) {
mutableStateOf(specificOptions.applyForThisFileOnly)
}
- var sortingMethod by remember(tab.activeFolder.path) {
+ var sortingMethod by remember(tab.activeFolder.uniquePath) {
mutableIntStateOf(specificOptions.sortMethod)
}
- var showFoldersFirst by remember(tab.activeFolder.path) {
+ var showFoldersFirst by remember(tab.activeFolder.uniquePath) {
mutableStateOf(specificOptions.showFoldersFirst)
}
- var reverseOrder by remember(tab.activeFolder.path) {
+ var reverseOrder by remember(tab.activeFolder.uniquePath) {
mutableStateOf(specificOptions.reverseSorting)
}
@@ -151,8 +151,8 @@ fun FileSortingMenu(
updateForThisFolder()
} else {
prefs.showFoldersFirst = showFoldersFirst
- prefs.reverseFilesSortingMethod = reverseOrder
- prefs.filesSortingMethod = sortingMethod
+ prefs.reverse = reverseOrder
+ prefs.defaultSortMethod = sortingMethod
}
reloadFiles()
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/OpenWithAppListDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/OpenWithAppListDialog.kt
similarity index 91%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/OpenWithAppListDialog.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/OpenWithAppListDialog.kt
index 67396897..aa511eb5 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/OpenWithAppListDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/OpenWithAppListDialog.kt
@@ -1,4 +1,4 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
@@ -28,10 +28,13 @@ import androidx.compose.ui.unit.sp
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
+import com.raival.compose.file.explorer.common.ui.DynamicSelectTextField
import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
import com.raival.compose.file.explorer.screen.main.tab.files.holder.OpenWithActivityHolder
import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.anyFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRow
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.ItemRowIcon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -39,12 +42,12 @@ import kotlinx.coroutines.launch
@Composable
fun OpenWithAppListDialog(tab: FilesTab) {
if (tab.openWithDialog.showOpenWithDialog && tab.openWithDialog.targetFile != null) {
- val documentHolder = tab.selectedFiles[tab.openWithDialog.targetFile!!.path]!!
+ val contentHolder = tab.openWithDialog.targetFile!!
val context = LocalContext.current
val appsList = remember {
mutableStateListOf().apply {
- addAll(documentHolder.getAppsHandlingFile())
+ addAll(contentHolder.getAppsHandlingFile())
}
}
@@ -57,7 +60,7 @@ fun OpenWithAppListDialog(tab: FilesTab) {
fun loadActivities(mimeType: String) {
loading.value = true
scope.launch(Dispatchers.IO) {
- val list = documentHolder.getAppsHandlingFile(mimeType)
+ val list = contentHolder.getAppsHandlingFile(mimeType)
appsList.clear()
appsList.addAll(list)
loading.value = false
@@ -90,7 +93,7 @@ fun OpenWithAppListDialog(tab: FilesTab) {
.fillMaxWidth(),
initValue = 0,
options = arrayListOf(
- Pair(documentHolder.mimeType, documentHolder.mimeType),
+ Pair(contentHolder.mimeType, contentHolder.mimeType),
Pair("image", "image/*"),
Pair("Video", "video/*"),
Pair("Audio", "audio/*"),
@@ -123,7 +126,7 @@ fun OpenWithAppListDialog(tab: FilesTab) {
.animateItem()
.combinedClickable(
onClick = {
- documentHolder.openFileWithPackage(
+ contentHolder.openFileWithPackage(
context,
item.packageName,
item.name
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/RenameDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/RenameDialog.kt
new file mode 100644
index 00000000..8e0f981d
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/RenameDialog.kt
@@ -0,0 +1,622 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.InsertDriveFile
+import androidx.compose.material.icons.rounded.ArrowForward
+import androidx.compose.material.icons.rounded.Folder
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.isValidAsFileName
+import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.task.RenameTask
+import com.raival.compose.file.explorer.screen.main.tab.files.task.RenameTask.Companion.transformFileName
+import com.raival.compose.file.explorer.screen.main.tab.files.task.RenameTaskParameters
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+@Composable
+fun RenameDialog(tab: FilesTab) {
+ if (tab.renameDialog.show
+ && tab.selectedFiles.isNotEmpty()
+ && tab.renameDialog.targetContent != null
+ ) {
+ if (tab.selectedFiles.size == 1) {
+ val target by remember {
+ mutableStateOf(tab.renameDialog.targetContent!!)
+ }
+ val listContent by remember {
+ mutableStateOf(tab.activeFolderContent.map { it.displayName }.toTypedArray())
+ }
+
+ var newNameInput by remember { mutableStateOf(target.displayName) }
+ var error by remember { mutableStateOf("") }
+
+ LaunchedEffect(newNameInput) {
+ error = if (newNameInput.isBlank() || newNameInput == target.displayName) {
+ emptyString
+ } else if (!newNameInput.isValidAsFileName()) {
+ globalClass.getString(R.string.invalid_file_name)
+ } else if (listContent.contains(newNameInput)) {
+ globalClass.getString(R.string.similar_file_exists)
+ } else {
+ emptyString
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { tab.renameDialog.hide() },
+ ) {
+ Card(
+ shape = RoundedCornerShape(6.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.rename),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = newNameInput,
+ onValueChange = {
+ newNameInput = it
+ },
+ label = { Text(text = stringResource(R.string.new_name)) },
+ singleLine = true,
+ shape = RoundedCornerShape(6.dp),
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ isError = error.isNotEmpty(),
+ supportingText = if (error.isNotEmpty()) {
+ { Text(error) }
+ } else null
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ tab.renameDialog.hide()
+ },
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.cancel),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ if (newNameInput.isValidAsFileName()) {
+ val similarFile = tab.activeFolder.findFile(newNameInput)
+ if (similarFile == null) {
+ tab.renameDialog.hide()
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.taskManager.addTaskAndRun(
+ task = RenameTask(sourceContent = tab.selectedFiles.values.toList()),
+ parameters = RenameTaskParameters(
+ newName = newNameInput,
+ toFind = emptyString,
+ toReplace = emptyString,
+ useRegex = false
+ )
+ )
+ }
+ } else {
+ globalClass.showMsg(R.string.similar_file_exists)
+ }
+ } else {
+ globalClass.showMsg(R.string.invalid_file_name)
+ }
+ },
+ enabled = error.isEmpty() && newNameInput.isNotBlank() && newNameInput != target.displayName,
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.rename),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ }
+ }
+ } else {
+ AdvanceRenameDialog(tab)
+ }
+ }
+}
+
+@Composable
+fun AdvanceRenameDialog(tab: FilesTab) {
+ val useDarkIcons = !isSystemInDarkTheme()
+ val originalList =
+ remember { tab.selectedFiles.values.map { it.displayName to it }.toTypedArray() }
+ val remainingFiles = tab.activeFolderContent.map { it.displayName }
+ .filter { !originalList.map { it.first }.contains(it) }.toList()
+ val currentList = remember { mutableStateListOf>() }
+ val conflicts = remember { mutableStateListOf() }
+ val suggestions = remember {
+ arrayListOf>().apply {
+ add("{p}" to globalClass.getString(R.string.name))
+ add("{s}" to globalClass.getString(R.string.extension))
+ add("{e}" to globalClass.getString(R.string.extension_with_dot))
+ add("{t}" to globalClass.getString(R.string.last_modified))
+ add("{n}" to globalClass.getString(R.string.number_increment))
+ add("{zn}" to globalClass.getString(R.string.zero_number_increment))
+ }
+ }
+
+ var isReady by remember { mutableStateOf(false) }
+ var useRegex by remember { mutableStateOf(false) }
+ var showInfo by remember { mutableStateOf(false) }
+ var newNameInput by remember { mutableStateOf(TextFieldValue("{p}{e}")) }
+ var findInput by remember { mutableStateOf(emptyString) }
+ var replaceInput by remember { mutableStateOf(emptyString) }
+
+ LaunchedEffect(Unit) {
+ currentList.clear()
+ conflicts.clear()
+ currentList.addAll(originalList)
+ }
+
+ fun preview() {
+ conflicts.clear()
+ currentList.clear()
+ val cumulativeNames = Array(originalList.size) { emptyString }
+ originalList.forEachIndexed { index, reference ->
+ val lastModified by lazy { reference.second.lastModified }
+ val newFileName = reference.first.transformFileName(
+ newName = newNameInput.text,
+ index = index,
+ textToFind = findInput,
+ replaceText = replaceInput,
+ useRegex = useRegex,
+ onLastModified = { lastModified }
+ )
+
+ currentList.add(newFileName to reference.second)
+
+ if (cumulativeNames.contains(newFileName) || remainingFiles.contains(newFileName)) {
+ conflicts.add(newFileName)
+ } else {
+ cumulativeNames[index] = newFileName
+ }
+ }
+ }
+
+ Dialog(
+ onDismissRequest = { tab.renameDialog.hide() },
+ properties = DialogProperties(
+ dismissOnClickOutside = true,
+ decorFitsSystemWindows = false,
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ val color = MaterialTheme.colorScheme.surfaceContainerHigh
+ val systemUiController = rememberSystemUiController()
+ DisposableEffect(systemUiController, useDarkIcons) {
+ systemUiController.setStatusBarColor(color = color, darkIcons = useDarkIcons)
+ onDispose {}
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxSize()
+ .imePadding()
+ .statusBarsPadding(),
+ shape = RectangleShape,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.batch_rename),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = newNameInput,
+ onValueChange = { newNameInput = it.also { isReady = false } },
+ label = { Text(text = stringResource(R.string.new_name)) },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp),
+ trailingIcon = {
+ IconButton(
+ onClick = { showInfo = true }
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Info,
+ contentDescription = null
+ )
+ }
+ },
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ )
+ )
+
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(items = suggestions, key = { it.first }) { suggestion ->
+ FilterChip(
+ onClick = {
+ val selection = newNameInput.selection
+ val newText: String
+ val newSelection: TextRange
+
+ if (selection.collapsed) {
+ val cursorPosition = selection.start
+ val text = newNameInput.text
+ val textBeforeCursor = text.substring(0, cursorPosition)
+ val textAfterCursor =
+ text.substring(cursorPosition, text.length)
+ newText = textBeforeCursor + suggestion.first + textAfterCursor
+ val newCursorPosition = cursorPosition + suggestion.first.length
+ newSelection = TextRange(newCursorPosition)
+ } else {
+ val text = newNameInput.text
+ val textBeforeSelection = text.substring(0, selection.start)
+ val textAfterSelection =
+ text.substring(selection.end, text.length)
+ newText =
+ textBeforeSelection + suggestion.first + textAfterSelection
+ val newCursorPosition =
+ selection.start + suggestion.first.length
+ newSelection = TextRange(newCursorPosition)
+ }
+
+ newNameInput = TextFieldValue(
+ text = newText,
+ selection = newSelection
+ )
+ isReady = false
+ },
+ label = {
+ Text(
+ text = suggestion.second,
+ style = MaterialTheme.typography.labelMedium
+ )
+ },
+ selected = false,
+ shape = RoundedCornerShape(8.dp)
+ )
+ }
+ }
+
+ // Find & Replace Section
+ Column(
+ modifier = Modifier,
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.find_n_replace),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ FilterChip(
+ onClick = { useRegex = !useRegex.also { isReady = false } },
+ label = {
+ Text(
+ text = stringResource(R.string.use_regex),
+ style = MaterialTheme.typography.labelMedium
+ )
+ },
+ selected = useRegex,
+ shape = RoundedCornerShape(8.dp)
+ )
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ TextField(
+ modifier = Modifier.weight(1f),
+ value = findInput,
+ onValueChange = { findInput = it.also { isReady = false } },
+ label = { Text(text = stringResource(R.string.text_to_find)) },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp),
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ )
+ )
+ TextField(
+ modifier = Modifier.weight(1f),
+ value = replaceInput,
+ onValueChange = { replaceInput = it.also { isReady = false } },
+ label = { Text(text = stringResource(R.string.replace_with)) },
+ singleLine = true,
+ shape = RoundedCornerShape(12.dp),
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ )
+ )
+ }
+ }
+
+ // Preview Section
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.preview),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer
+ ),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ itemsIndexed(
+ items = currentList,
+ key = { index, item -> item.first + item.second.displayName }) { index, item ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = if (item.second.isFolder)
+ Icons.Rounded.Folder
+ else Icons.AutoMirrored.Rounded.InsertDriveFile,
+ contentDescription = null,
+ tint = if (conflicts.contains(item.first))
+ MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
+ )
+ Space(12.dp)
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = item.second.displayName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = if (conflicts.contains(item.first))
+ MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.ArrowForward,
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ tint = if (conflicts.contains(item.first))
+ MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.primary
+ )
+ Space(8.dp)
+ Text(
+ text = item.first,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = if (conflicts.contains(item.first))
+ MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ if (index != currentList.lastIndex) {
+ Space(8.dp)
+ HorizontalDivider()
+ }
+ }
+ }
+ }
+ }
+
+ // Action Buttons
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = { tab.renameDialog.hide() },
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.cancel),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ if (isReady && conflicts.isEmpty()) {
+ tab.renameDialog.hide()
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.taskManager.addTaskAndRun(
+ task = RenameTask(tab.selectedFiles.values.toList()),
+ parameters = RenameTaskParameters(
+ newName = newNameInput.text,
+ toFind = findInput,
+ toReplace = replaceInput,
+ useRegex = useRegex
+ )
+ )
+ }
+ } else {
+ preview()
+ isReady = true
+ }
+ },
+ enabled = !isReady || conflicts.isEmpty(),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Text(
+ text = stringResource(if (isReady) R.string.rename else R.string.preview),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (showInfo) {
+ AlertDialog(
+ onDismissRequest = { showInfo = false },
+ title = {
+ Text(
+ text = stringResource(R.string.syntax),
+ style = MaterialTheme.typography.headlineSmall
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(R.string.batch_rename_info),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { showInfo = false },
+ shape = RoundedCornerShape(8.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.close),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ },
+ shape = RoundedCornerShape(16.dp)
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/SearchDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/SearchDialog.kt
similarity index 84%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/SearchDialog.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/SearchDialog.kt
index 31e36c49..dc1568e8 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/SearchDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/SearchDialog.kt
@@ -1,15 +1,18 @@
-package com.raival.compose.file.explorer.screen.main.tab.files.ui
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
@@ -31,6 +34,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -48,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.copyToClipboard
@@ -56,7 +61,10 @@ import com.raival.compose.file.explorer.common.extension.getIndexIf
import com.raival.compose.file.explorer.common.ui.Space
import com.raival.compose.file.explorer.common.ui.block
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.ui.FileItemRow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -68,6 +76,7 @@ fun SearchDialog(tab: FilesTab) {
if (tab.showSearchPenal) {
val context = LocalContext.current
var isSearching by remember { mutableStateOf(false) }
+ val useDarkIcons = !isSystemInDarkTheme()
fun search(query: String) {
if (query.isNotEmpty()) {
@@ -79,7 +88,7 @@ fun SearchDialog(tab: FilesTab) {
val searchLimit = globalClass
.preferencesManager
- .generalPrefs
+ .fileOperationPrefs
.searchInFilesLimit
val isExceedingTheSearchLimit =
@@ -89,19 +98,19 @@ fun SearchDialog(tab: FilesTab) {
tab.activeFolderContent.forEach {
if (!isSearching) return
- if (it.getName().contains(query, true)) {
+ if (it.displayName.contains(query, true)) {
tab.search.searchResults += it
delay(150)
}
}
}
- suspend fun searchIn(doc: DocumentHolder) {
+ suspend fun searchIn(contentHolder: ContentHolder) {
if (!isSearching) return
val searchLimit = globalClass
.preferencesManager
- .generalPrefs
+ .fileOperationPrefs
.searchInFilesLimit
val isExceedingTheSearchLimit =
@@ -109,25 +118,25 @@ fun SearchDialog(tab: FilesTab) {
if (isExceedingTheSearchLimit) return
- if (doc.isFile) {
- if (doc.getName().contains(query, true)) {
+ if (contentHolder.isFile()) {
+ if (contentHolder.displayName.contains(query, true)) {
if (!isSearching) return
- tab.search.searchResults += doc
+ tab.search.searchResults += contentHolder
delay(150)
}
}
- if (doc.isFolder) {
+ if (contentHolder.isFolder) {
if (!isSearching) return
- doc.documentFile.listFiles().forEach {
+ contentHolder.listContent().forEach {
if (!isSearching) return
- searchIn(DocumentHolder(it))
+ searchIn(it)
}
}
}
CoroutineScope(Dispatchers.IO).launch {
- if (tab.isSpecialDirectory()) {
+ if (tab.activeFolder is VirtualFileHolder) {
searchInList()
} else {
searchIn(tab.activeFolder)
@@ -144,13 +153,24 @@ fun SearchDialog(tab: FilesTab) {
Dialog(
onDismissRequest = { tab.showSearchPenal = false },
properties = DialogProperties(
+ dismissOnClickOutside = false,
decorFitsSystemWindows = false,
usePlatformDefaultWidth = false
)
) {
+ // There is a strange artifact that overlaps with the status bar, not sure why it occurs.
+ // This code is a workaround to fix it.
+ val color = MaterialTheme.colorScheme.surfaceContainerHigh
+ val systemUiController = rememberSystemUiController()
+ DisposableEffect(systemUiController, useDarkIcons) {
+ systemUiController.setStatusBarColor(color = color, darkIcons = useDarkIcons)
+ onDispose {}
+ }
Column(
modifier = Modifier
.fillMaxSize()
+ .imePadding()
+ .statusBarsPadding()
.block(
shape = RectangleShape,
borderSize = 0.dp
@@ -261,8 +281,8 @@ fun SearchDialog(tab: FilesTab) {
LazyColumn {
itemsIndexed(
tab.search.searchResults,
- key = { index, item -> item.path }) { index, item ->
- var showMoreOptionsMenu by remember(item.path) {
+ key = { index, item -> item.uniquePath }) { index, item ->
+ var showMoreOptionsMenu by remember(item.uniquePath) {
mutableStateOf(false)
}
@@ -271,7 +291,7 @@ fun SearchDialog(tab: FilesTab) {
.fillMaxWidth()
.combinedClickable(
onClick = {
- if (item.isFile) {
+ if (item.isFile()) {
tab.openFile(context, item)
} else {
tab.openFolder(item, rememberListState = false)
@@ -283,7 +303,10 @@ fun SearchDialog(tab: FilesTab) {
)
) {
Space(size = 4.dp)
- FileItemRow(item = item, fileDetails = item.basePath)
+ FileItemRow(
+ item = item, fileDetails = if (item is LocalFileHolder)
+ item.basePath else item.uniquePath
+ )
Space(size = 4.dp)
HorizontalDivider(
modifier = Modifier.padding(start = 56.dp),
@@ -298,15 +321,15 @@ fun SearchDialog(tab: FilesTab) {
},
onClick = {
tab.showSearchPenal = false
- if (item.canAccessParent) {
+ if (item.hasParent()) {
tab.highlightedFiles.apply {
clear()
- add(item.path)
+ add(item.uniquePath)
}
- tab.openFolder(item.parent!!) {
+ tab.openFolder(item.getParent()!!) {
CoroutineScope(Dispatchers.Main).launch {
tab.getFileListState().scrollToItem(
- tab.activeFolderContent.getIndexIf { path == item.path },
+ tab.activeFolderContent.getIndexIf { uniquePath == item.uniquePath },
0
)
}
@@ -321,7 +344,7 @@ fun SearchDialog(tab: FilesTab) {
},
onClick = {
showMoreOptionsMenu = false
- item.path.copyToClipboard()
+ item.uniquePath.copyToClipboard()
globalClass.showMsg(globalClass.getString(R.string.copied_to_clipboard))
}
)
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskConflictDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskConflictDialog.kt
new file mode 100644
index 00000000..d69d72e5
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskConflictDialog.kt
@@ -0,0 +1,86 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.ui.CheckableText
+import com.raival.compose.file.explorer.common.ui.block
+import com.raival.compose.file.explorer.screen.main.tab.files.task.TaskContentStatus
+
+@Composable
+fun TaskConflictDialog() {
+ val interceptor = globalClass.taskManager.taskInterceptor
+ var applyToOtherConflicts by remember { mutableStateOf(interceptor.hasConflict) }
+
+ if (interceptor.hasConflict) {
+ Dialog(
+ onDismissRequest = { },
+ properties = DialogProperties(
+ dismissOnBackPress = false,
+ dismissOnClickOutside = false
+ )
+ ) {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .block()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = globalClass.getString(R.string.conflict),
+ fontSize = 18.sp
+ )
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = interceptor.message,
+ fontSize = 14.sp
+ )
+ CheckableText(
+ checked = applyToOtherConflicts,
+ onCheckedChange = { applyToOtherConflicts = it }
+ ) {
+ Text(text = globalClass.getString(R.string.apply_to_other_conflicts))
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ TextButton(onClick = {
+ interceptor.hide()
+ }) {
+ Text(text = globalClass.getString(R.string.cancel))
+ }
+ Spacer(Modifier.weight(1f))
+ TextButton(onClick = {
+ interceptor.resolve(TaskContentStatus.REPLACE, applyToOtherConflicts)
+ }) {
+ Text(text = globalClass.getString(R.string.replace))
+ }
+ TextButton(onClick = {
+ interceptor.resolve(TaskContentStatus.SKIP, applyToOtherConflicts)
+ }) {
+ Text(text = globalClass.getString(R.string.skip))
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskPanel.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskPanel.kt
new file mode 100644
index 00000000..20ef8efa
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskPanel.kt
@@ -0,0 +1,695 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.ExperimentalMaterialApi
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.HourglassEmpty
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.Warning
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.rounded.Task
+import androidx.compose.material.icons.rounded.TaskAlt
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
+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 androidx.compose.ui.unit.dp
+import com.kevinnzou.compose.swipebox.SwipeBox
+import com.kevinnzou.compose.swipebox.SwipeDirection
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.ui.BottomSheetDialog
+import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.service.ContentOperationService.Companion.removeBackgroundTask
+import com.raival.compose.file.explorer.screen.main.tab.files.task.CompressTask
+import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTask
+import com.raival.compose.file.explorer.screen.main.tab.files.task.CopyTaskParameters
+import com.raival.compose.file.explorer.screen.main.tab.files.task.Task
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlin.math.max
+
+@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
+@Composable
+fun TaskPanel(tab: FilesTab) {
+ if (tab.showTasksPanel) {
+ val coroutineScope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ val runningTasks = remember { mutableStateListOf() }
+ val pausedTasks = remember { mutableStateListOf() }
+ val failedTasks = remember { mutableStateListOf() }
+ val pendingTasks = remember { mutableStateListOf() }
+ val invalidTasks = remember { mutableStateListOf() }
+
+ LaunchedEffect(Unit) {
+ globalClass.taskManager.validateTasks()
+
+ while (tab.showTasksPanel) {
+ if (globalClass.taskManager.runningTasks != runningTasks) {
+ runningTasks.clear()
+ runningTasks.addAll(globalClass.taskManager.runningTasks)
+ }
+ if (globalClass.taskManager.pausedTasks != pausedTasks) {
+ pausedTasks.clear()
+ pausedTasks.addAll(globalClass.taskManager.pausedTasks)
+ }
+ if (globalClass.taskManager.failedTasks != failedTasks) {
+ failedTasks.clear()
+ failedTasks.addAll(globalClass.taskManager.failedTasks)
+ }
+ if (globalClass.taskManager.pendingTasks != pendingTasks) {
+ pendingTasks.clear()
+ pendingTasks.addAll(globalClass.taskManager.pendingTasks)
+ }
+ if (globalClass.taskManager.invalidTasks != invalidTasks) {
+ invalidTasks.clear()
+ invalidTasks.addAll(globalClass.taskManager.invalidTasks)
+ }
+ delay(250)
+ }
+ }
+
+ BottomSheetDialog(
+ onDismissRequest = { tab.showTasksPanel = false }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 16.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.TaskAlt,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(24.dp)
+ )
+ Space(8.dp)
+ Text(
+ text = stringResource(R.string.tasks),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ AnimatedVisibility(
+ !runningTasks.isEmpty() || !pausedTasks.isEmpty() ||
+ !failedTasks.isEmpty() || !pendingTasks.isEmpty() || !invalidTasks.isEmpty()
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize(animationSpec = tween(300)),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Running Tasks
+ if (runningTasks.isNotEmpty()) {
+ item {
+ TaskCategoryHeader(
+ title = stringResource(R.string.tasks_running),
+ count = runningTasks.size,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ items(runningTasks, key = { "running-${it.id}" }) { task ->
+ RunningTaskItem(
+ task = task,
+ onClick = {
+ tab.showTasksPanel = false
+ globalClass.taskManager.bringToForeground(task)
+ }
+ )
+ }
+ item { Space(8.dp) }
+ }
+
+ // Paused Tasks
+ if (pausedTasks.isNotEmpty()) {
+ item {
+ TaskCategoryHeader(
+ title = stringResource(R.string.tasks_paused),
+ count = pausedTasks.size,
+ color = MaterialTheme.colorScheme.tertiary
+ )
+ }
+ items(pausedTasks, key = { "paused-${it.id}" }) { task ->
+ SwipeableTaskItem(
+ task = task,
+ icon = Icons.Default.Pause,
+ iconTint = MaterialTheme.colorScheme.tertiary,
+ onSwipeToDelete = {
+ pausedTasks.remove(task)
+ coroutineScope.launch {
+ globalClass.taskManager.removeTask(task.id)
+ removeBackgroundTask(context, task.id)
+ }
+ },
+ onClick = {
+ coroutineScope.launch {
+ globalClass.taskManager.continueTask(task.id)
+ }
+ tab.showTasksPanel = false
+ }
+ )
+ }
+ item { Space(8.dp) }
+ }
+
+ // Failed Tasks
+ if (failedTasks.isNotEmpty()) {
+ item {
+ TaskCategoryHeader(
+ title = stringResource(R.string.tasks_failed),
+ count = failedTasks.size,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ items(failedTasks, key = { "failed-${it.id}" }) { task ->
+ SwipeableTaskItem(
+ task = task,
+ icon = Icons.Default.ErrorOutline,
+ iconTint = MaterialTheme.colorScheme.error,
+ onSwipeToDelete = {
+ failedTasks.remove(task)
+ coroutineScope.launch {
+ globalClass.taskManager.removeTask(task.id)
+ removeBackgroundTask(context, task.id)
+ }
+ },
+ onClick = {
+ coroutineScope.launch {
+ globalClass.taskManager.continueTask(task.id)
+ }
+ tab.showTasksPanel = false
+ }
+ )
+ }
+ item { Space(8.dp) }
+ }
+
+ // Pending Tasks
+ if (pendingTasks.isNotEmpty()) {
+ item {
+ TaskCategoryHeader(
+ title = stringResource(R.string.tasks_pending),
+ count = pendingTasks.size,
+ color = MaterialTheme.colorScheme.secondary
+ )
+ }
+ items(pendingTasks, key = { "pending-${it.id}" }) { task ->
+ SwipeableTaskItem(
+ task = task,
+ icon = Icons.Default.HourglassEmpty,
+ iconTint = MaterialTheme.colorScheme.secondary,
+ onSwipeToDelete = {
+ pendingTasks.remove(task)
+ coroutineScope.launch {
+ globalClass.taskManager.removeTask(task.id)
+ }
+ },
+ onClick = {
+ if (tab.activeFolder is VirtualFileHolder || tab !is FilesTab) {
+ globalClass.showMsg(globalClass.getString(R.string.can_not_run_tasks))
+ return@SwipeableTaskItem
+ }
+ tab.showTasksPanel = false
+ when (task) {
+ is CopyTask -> {
+ coroutineScope.launch {
+ globalClass.taskManager.runTask(
+ task.id,
+ CopyTaskParameters(tab.activeFolder)
+ )
+ }
+ }
+
+ is CompressTask -> tab.newZipFileDialog.show(task)
+ }
+ }
+ )
+ }
+ item { Space(8.dp) }
+ }
+
+ // Invalid Tasks
+ if (invalidTasks.isNotEmpty()) {
+ item {
+ TaskCategoryHeader(
+ title = stringResource(R.string.tasks_invalid),
+ count = invalidTasks.size,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ items(invalidTasks, key = { "invalid-${it.id}" }) { task ->
+ SwipeableTaskItem(
+ task = task,
+ icon = Icons.Default.Warning,
+ iconTint = MaterialTheme.colorScheme.outline,
+ onSwipeToDelete = {
+ invalidTasks.remove(task)
+ coroutineScope.launch {
+ globalClass.taskManager.removeTask(task.id)
+ }
+ },
+ onClick = { }
+ )
+ }
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = runningTasks.isEmpty() && pausedTasks.isEmpty() &&
+ failedTasks.isEmpty() && pendingTasks.isEmpty() && invalidTasks.isEmpty()
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 64.dp, horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Task,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.outline,
+ modifier = Modifier.size(48.dp)
+ )
+ Space(16.dp)
+ Text(
+ text = stringResource(R.string.empty),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ Space(8.dp)
+ Text(
+ text = stringResource(R.string.no_tasks_available),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ textAlign = TextAlign.Center
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun TaskCategoryHeader(
+ title: String,
+ count: Int,
+ color: Color
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = color.copy(alpha = 0.1f)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(8.dp)
+ .background(color, shape = RoundedCornerShape(50))
+ )
+ Space(12.dp)
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f)
+ )
+ Surface(
+ shape = RoundedCornerShape(12.dp),
+ color = color.copy(alpha = 0.2f)
+ ) {
+ Text(
+ text = count.toString(),
+ style = MaterialTheme.typography.labelMedium,
+ color = color,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RunningTaskItem(
+ task: Task,
+ onClick: () -> Unit
+) {
+ val job = remember { SupervisorJob() }
+ val scope = remember { CoroutineScope(Dispatchers.IO + job) }
+
+ var progress by remember(task.id) { mutableFloatStateOf(task.progressMonitor.progress) }
+ var processName by remember(task.id) { mutableStateOf(task.progressMonitor.processName) }
+ var contentName by remember(task.id) { mutableStateOf(task.progressMonitor.contentName) }
+ var totalContent by remember(task.id) { mutableIntStateOf(task.progressMonitor.totalContent) }
+ var remainingContent by remember(task.id) { mutableIntStateOf(task.progressMonitor.remainingContent) }
+
+ DisposableEffect(task.id) {
+ scope.launch {
+ while (true) {
+ progress = task.progressMonitor.progress
+ processName = task.progressMonitor.processName
+ contentName = task.progressMonitor.contentName
+ totalContent = task.progressMonitor.totalContent
+ remainingContent = task.progressMonitor.remainingContent
+ delay(100)
+ }
+ }
+
+ onDispose {
+ job.cancel()
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() },
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Space(16.dp)
+ Column(modifier = Modifier.weight(1f)) {
+ if (processName.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = processName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f, fill = false)
+ )
+ if (contentName.isNotEmpty()) {
+ Text(
+ text = " - $contentName",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+ if (progress > 0) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.primaryContainer
+ )
+
+ // Progress Info Row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Content Counter
+ if (totalContent > 0) {
+ Text(
+ text = "${
+ max(
+ totalContent - remainingContent,
+ 0
+ )
+ }/${totalContent}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // Percentage
+ Text(
+ text = "${(progress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ } else {
+ // Indeterminate progress
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.primaryContainer
+ )
+
+ if (totalContent > 0) {
+ Text(
+ text = "${totalContent - remainingContent}/${totalContent}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.align(Alignment.Start)
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
+@Composable
+private fun SwipeableTaskItem(
+ task: Task,
+ icon: ImageVector,
+ iconTint: Color,
+ onSwipeToDelete: () -> Unit,
+ onClick: () -> Unit
+) {
+ val coroutineScope = rememberCoroutineScope()
+ SwipeBox(
+ modifier = Modifier
+ .fillMaxWidth()
+ .animateContentSize(),
+ swipeDirection = SwipeDirection.EndToStart,
+ endContentWidth = 72.dp,
+ endContent = { swipeableState, _ ->
+ Box(
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(72.dp)
+ .background(
+ MaterialTheme.colorScheme.errorContainer,
+ shape = RoundedCornerShape(topEnd = 12.dp, bottomEnd = 12.dp)
+ )
+ .clickable {
+ onSwipeToDelete()
+ coroutineScope.launch { swipeableState.animateTo(0) }
+ },
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Delete,
+ contentDescription = stringResource(R.string.delete),
+ tint = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ ) { _, _, _ ->
+ TaskItem(
+ task = task,
+ icon = icon,
+ iconTint = iconTint,
+ onClick = onClick
+ )
+ }
+}
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun TaskItem(
+ task: Task,
+ icon: ImageVector,
+ iconTint: Color,
+ onClick: () -> Unit
+) {
+ var expanded by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = onClick,
+ onLongClick = {
+ expanded = !expanded
+ }
+ )
+ .animateContentSize(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Surface(
+ shape = RoundedCornerShape(8.dp),
+ color = iconTint.copy(alpha = 0.1f),
+ modifier = Modifier.size(40.dp)
+ ) {
+ Box(
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = iconTint,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+
+ Space(16.dp)
+
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = task.metadata.title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Medium,
+ )
+ if (task.metadata.subtitle.isNotBlank()) {
+ Space(4.dp)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ modifier = Modifier.weight(1f),
+ text = task.metadata.subtitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = task.metadata.creationTime,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ AnimatedVisibility(
+ visible = expanded,
+ enter = scaleIn() + slideInVertically { -it },
+ exit = scaleOut(targetScale = 0.5f) + slideOutVertically { -it / 4 } + fadeOut()
+ ) {
+ Text(
+ text = task.metadata.displayDetails,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
+ maxLines = 3
+ )
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskRunningDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskRunningDialog.kt
new file mode 100644
index 00000000..f0f2acc1
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/ui/dialog/TaskRunningDialog.kt
@@ -0,0 +1,254 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.ui.dialog
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+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 androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.ui.Space
+import kotlin.math.max
+
+@Composable
+fun TaskRunningDialog() {
+ val dialogInfo = globalClass.taskManager.runningTaskDialogInfo
+ val progressMonitor = dialogInfo.progressMonitor
+ val showDialog = dialogInfo.showDialog
+ val linkedTask = dialogInfo.linkedTask
+ if (showDialog && progressMonitor != null && linkedTask != null) {
+ var isAborted by remember { mutableStateOf(false) }
+ val isHidden by remember { mutableStateOf(false) }
+ Dialog(
+ onDismissRequest = { },
+ properties = DialogProperties(
+ dismissOnBackPress = false,
+ dismissOnClickOutside = false
+ )
+ ) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ shape = RoundedCornerShape(6.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 6.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Header Section
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ // Task Title
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = progressMonitor.taskTitle.ifEmpty { stringResource(R.string.preparing) },
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ // Process Name and Content
+ if (progressMonitor.processName.isNotEmpty()) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = progressMonitor.processName,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f, fill = false)
+ )
+ if (progressMonitor.contentName.isNotEmpty()) {
+ Text(
+ text = " - ${progressMonitor.contentName}",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+ }
+ }
+
+ // Progress Section
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ // Progress Bar
+ if (progressMonitor.progress > 0) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ LinearProgressIndicator(
+ progress = { progressMonitor.progress },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.primaryContainer
+ )
+
+ // Progress Info Row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Content Counter
+ if (progressMonitor.totalContent > 0) {
+ Text(
+ text = "${
+ max(
+ progressMonitor.totalContent - progressMonitor.remainingContent,
+ 0
+ )
+ }/${progressMonitor.totalContent}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // Percentage
+ Text(
+ text = "${(progressMonitor.progress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ } else {
+ // Indeterminate progress
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(8.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ color = MaterialTheme.colorScheme.primary,
+ trackColor = MaterialTheme.colorScheme.primaryContainer
+ )
+
+ if (progressMonitor.totalContent > 0) {
+ Text(
+ text = "${progressMonitor.totalContent - progressMonitor.remainingContent}/${progressMonitor.totalContent}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.align(Alignment.Start)
+ )
+ }
+ }
+ }
+ }
+
+ // Actions Section
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Move to Background Button
+ if (linkedTask.metadata.canMoveToBackground) {
+ TextButton(
+ enabled = !isHidden && !isAborted,
+ onClick = {
+ globalClass.taskManager.hideRunningTaskDialog()
+ },
+ colors = ButtonDefaults.textButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.hide),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+
+ if (linkedTask.metadata.isCancellable) {
+ Space(8.dp)
+ }
+ }
+
+ // Cancel/Stop Button
+ if (linkedTask.metadata.isCancellable) {
+ FilledTonalButton(
+ onClick = {
+ linkedTask.abortTask()
+ isAborted = true
+ },
+ enabled = !isAborted && !isHidden,
+ colors = ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = if (isAborted) stringResource(R.string.stopping) else stringResource(
+ R.string.stop
+ ),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipManager.kt
new file mode 100644
index 00000000..3fdb7c12
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipManager.kt
@@ -0,0 +1,51 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.zip
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+
+class ZipManager {
+ private val archiveList = hashMapOf()
+
+ fun checkForSourceChanges(): Boolean {
+ var foundChanges = false
+ archiveList.values.forEach { zipTree ->
+ if (zipTree.source.lastModified != zipTree.timeStamp) {
+ zipTree.reset()
+ foundChanges = true
+ }
+ }
+ return foundChanges
+ }
+
+ fun openArchive(archive: LocalFileHolder) {
+ val existingTreeKey = archiveList.keys.find { archive.uniquePath == it.uniquePath }
+
+ if (existingTreeKey != null) {
+ archiveList[existingTreeKey]?.let { existingTree ->
+ if (existingTree.timeStamp == archive.lastModified) {
+ globalClass.mainActivityManager.let { mainManager ->
+ (mainManager.getActiveTab() as? FilesTab)?.openFolder(
+ existingTree.createRootContentHolder()
+ ) ?: mainManager.replaceCurrentTabWith(
+ FilesTab(existingTree.createRootContentHolder())
+ )
+ return
+ }
+ } else {
+ archiveList.remove(existingTreeKey)
+ }
+ }
+ }
+
+ archiveList[archive] = ZipTree(archive).apply {
+ globalClass.mainActivityManager.let { mainManager ->
+ (mainManager.getActiveTab() as? FilesTab)?.openFolder(
+ createRootContentHolder()
+ ) ?: mainManager.replaceCurrentTabWith(
+ FilesTab(createRootContentHolder())
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipTree.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipTree.kt
new file mode 100644
index 00000000..6084fbdc
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/ZipTree.kt
@@ -0,0 +1,109 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.zip
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ZipFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.zip.model.ZipNode
+import java.io.File
+import java.util.Enumeration
+import java.util.zip.ZipEntry
+import java.util.zip.ZipFile
+
+class ZipTree(
+ val source: LocalFileHolder,
+) {
+ val timeStamp = source.lastModified
+
+ private val zipEntries = hashMapOf()
+ private val nodes = hashMapOf()
+
+ private val root = ZipNode(
+ name = source.displayName,
+ path = emptyString,
+ isDirectory = true,
+ lastModified = 0,
+ lastAccessed = 0,
+ size = 0
+ )
+
+ var isReady = false
+ private set
+
+ fun getRootNode() = root
+
+ fun createRootContentHolder() = ZipFileHolder(this, root)
+
+ fun findNodeByPath(path: String) = nodes[path]
+
+ fun reset() {
+ isReady = false
+ }
+
+ fun prepare() {
+ build()
+ }
+
+ private fun build() {
+ isReady = false
+ zipEntries.clear()
+
+ try {
+ val zipFile = ZipFile(source.file)
+ val enum: Enumeration<*> = zipFile.entries()
+ while (enum.hasMoreElements()) {
+ val entry = enum.nextElement() as ZipEntry
+ zipEntries.put(entry.name, entry)
+ }
+ } catch (e: Exception) {
+ logger.logError(e)
+ globalClass.showMsg(R.string.invalid_zip)
+ }
+
+ buildTree()
+
+ isReady = true
+ }
+
+ private fun buildTree() {
+ nodes.clear()
+ nodes.put(emptyString, root.apply { children.clear() })
+
+ zipEntries.forEach { entry ->
+ val path = entry.key
+
+ val parts = path.split(File.separator)
+ var currentNode = root
+ var currentPath = root.path
+
+ for ((i, part) in parts.withIndex()) {
+ if (part.isNotEmpty()) {
+ val childNode = currentNode.children.find { it.name == part }
+
+ currentPath = if (currentPath.isEmpty()) part else "$currentPath/$part"
+
+ if (childNode == null) {
+ val newNode = ZipNode(
+ name = part,
+ path = currentPath,
+ isDirectory = if (i < parts.lastIndex) true else entry.value.isDirectory,
+ lastModified = entry.value.lastModifiedTime?.toMillis() ?: 0,
+ lastAccessed = entry.value.lastAccessTime?.toMillis() ?: 0,
+ size = entry.value.size
+ )
+ currentNode.children.add(newNode)
+ currentNode = newNode
+
+ nodes.put(currentPath, newNode)
+ } else {
+ currentNode = childNode
+ }
+ }
+ }
+ }
+
+ zipEntries.clear()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/model/ZipNode.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/model/ZipNode.kt
new file mode 100644
index 00000000..24815395
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/files/zip/model/ZipNode.kt
@@ -0,0 +1,86 @@
+package com.raival.compose.file.explorer.screen.main.tab.files.zip.model
+
+import com.raival.compose.file.explorer.common.extension.emptyString
+import java.io.File
+
+data class ZipNode(
+ val name: String,
+ val path: String,
+ val size: Long = 0L,
+ val isDirectory: Boolean = true,
+ val lastModified: Long = 0L,
+ val lastAccessed: Long = 0L,
+ val children: MutableList = mutableListOf()
+) {
+ val extension: String
+ get() {
+ if (this.isDirectory) {
+ return emptyString
+ }
+
+ val dotIndex = this.name.lastIndexOf('.')
+
+ // If a dot exists and it is not the first character in the name,
+ // return the substring after the dot.
+ return if (dotIndex > 0) {
+ this.name.substring(dotIndex + 1).lowercase()
+ } else {
+ // No extension found (or it's a dotfile like ".gitignore").
+ emptyString
+ }
+ }
+
+ val parentPath: String
+ get() {
+ // First, remove any trailing slash if the entry is a directory.
+ // This ensures that for "folder/", we are working with "folder".
+ val path = this.path.removeSuffix(File.separator)
+
+ // Find the last occurrence of the path separator.
+ val lastSlashIndex = path.lastIndexOf(File.separatorChar)
+
+ // If no slash is found, the entry is in the root.
+ // Otherwise, the parent path is everything up to and including the slash.
+ return if (lastSlashIndex == -1) {
+ emptyString
+ } else {
+ path.substring(0, lastSlashIndex)
+ }
+ }
+
+ fun listFilesAndEmptyDirs(): List {
+ val result = mutableListOf()
+
+ // A queue of directories to visit. Start with the root.
+ val directoryQueue = ArrayDeque()
+ if (isDirectory) {
+ directoryQueue.add(this)
+ } else {
+ // If the starting path is just a file, add it and return.
+ result.add(this)
+ return result
+ }
+
+ while (directoryQueue.isNotEmpty()) {
+ val dir = directoryQueue.removeFirst()
+ // listFiles() can be null if the directory is not accessible
+ val children = dir.children
+
+ if (children.isEmpty()) {
+ // This directory is empty, add it to the list.
+ result.add(dir)
+ } else {
+ // Process the contents of the non-empty directory
+ for (child in children) {
+ if (child.isDirectory) {
+ directoryQueue.add(child)
+ } else {
+ result.add(child)
+ }
+ }
+ }
+ }
+
+ return result
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt
index ea9862ed..8cec0a95 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/HomeTab.kt
@@ -11,16 +11,24 @@ import androidx.compose.material.icons.rounded.Archive
import androidx.compose.material.icons.rounded.AudioFile
import androidx.compose.material.icons.rounded.Image
import androidx.compose.material.icons.rounded.VideoFile
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.screen.main.tab.Tab
import com.raival.compose.file.explorer.screen.main.tab.apps.AppsTab
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider
-import com.raival.compose.file.explorer.screen.main.tab.home.holder.HomeCategoryHolder
-import com.raival.compose.file.explorer.screen.main.tab.home.holder.RecentFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.ARCHIVE
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.AUDIO
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.DOCUMENT
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.IMAGE
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.VIDEO
+import com.raival.compose.file.explorer.screen.main.tab.home.holder.HomeCategory
+import com.raival.compose.file.explorer.screen.main.tab.home.holder.RecentFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -28,14 +36,12 @@ import java.io.File
class HomeTab : Tab() {
override val id = globalClass.generateUid()
-
override val title = globalClass.getString(R.string.home_tab_title)
-
override val subtitle = emptyString
-
override val header = globalClass.getString(R.string.home_tab_header)
+ val recentFiles = mutableStateListOf()
- val recentFileHolders = mutableStateListOf()
+ var showCustomizeHomeTabDialog by mutableStateOf(false)
override fun onTabStarted() {
super.onTabStarted()
@@ -48,77 +54,77 @@ class HomeTab : Tab() {
}
fun fetchRecentFiles() {
- if (recentFileHolders.isNotEmpty()) return
+ if (recentFiles.isNotEmpty()) return
CoroutineScope(Dispatchers.IO).launch {
- recentFileHolders.addAll(getRecentFiles())
+ recentFiles.addAll(getRecentFiles())
}
}
- fun getMainCategories() = arrayListOf().apply {
+ fun getMainCategories() = arrayListOf().apply {
val mainActivityManager = globalClass.mainActivityManager
add(
- HomeCategoryHolder(
+ HomeCategory(
name = globalClass.getString(R.string.images),
icon = Icons.Rounded.Image,
onClick = {
mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.images)
+ FilesTab(VirtualFileHolder(IMAGE))
)
}
)
)
add(
- HomeCategoryHolder(
+ HomeCategory(
name = globalClass.getString(R.string.videos),
icon = Icons.Rounded.VideoFile,
onClick = {
mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.videos)
+ FilesTab(VirtualFileHolder(VIDEO))
)
}
)
)
add(
- HomeCategoryHolder(
+ HomeCategory(
name = globalClass.getString(R.string.audios),
icon = Icons.Rounded.AudioFile,
onClick = {
mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.audios)
+ FilesTab(VirtualFileHolder(AUDIO))
)
}
)
)
add(
- HomeCategoryHolder(
+ HomeCategory(
name = globalClass.getString(R.string.documents),
icon = Icons.AutoMirrored.Rounded.InsertDriveFile,
onClick = {
mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.documents)
+ FilesTab(VirtualFileHolder(DOCUMENT))
)
}
)
)
add(
- HomeCategoryHolder(
+ HomeCategory(
name = globalClass.getString(R.string.archives),
icon = Icons.Rounded.Archive,
onClick = {
mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.archives)
+ FilesTab(VirtualFileHolder(ARCHIVE))
)
}
)
)
add(
- HomeCategoryHolder(
+ HomeCategory(
name = globalClass.getString(R.string.apps),
icon = Icons.Rounded.Android,
onClick = {
@@ -130,10 +136,10 @@ class HomeTab : Tab() {
)
}
- private fun getRecentFiles(): ArrayList {
- val recentFileHolders = ArrayList()
+ private fun getRecentFiles(): ArrayList {
+ val recentFiles = ArrayList()
val contentResolver: ContentResolver = globalClass.contentResolver
- val showHiddenFiles = globalClass.preferencesManager.displayPrefs.showHiddenFiles
+ val showHiddenFiles = globalClass.preferencesManager.fileListPrefs.showHiddenFiles
val uri: Uri = MediaStore.Files.getContentUri("external")
@@ -164,7 +170,7 @@ class HomeTab : Tab() {
it.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DATE_MODIFIED)
val columnName = it.getColumnIndexOrThrow(MediaStore.Files.FileColumns.DISPLAY_NAME)
- while (it.moveToNext() && recentFileHolders.size < 15) {
+ while (it.moveToNext() && recentFiles.size < 15) {
val filePath = it.getString(columnIndexPath)
val lastModified = it.getLong(columnLastModified)
val name = it.getString(columnName)
@@ -172,8 +178,8 @@ class HomeTab : Tab() {
if (file.isFile && filePath != null && name != null &&
(showHiddenFiles || !file.name.startsWith("."))
) {
- recentFileHolders.add(
- RecentFileHolder(
+ recentFiles.add(
+ RecentFile(
name,
filePath,
lastModified
@@ -183,6 +189,6 @@ class HomeTab : Tab() {
}
}
- return recentFileHolders
+ return recentFiles
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt
new file mode 100644
index 00000000..6e76455f
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/data/HomeLayoutPreferences.kt
@@ -0,0 +1,76 @@
+package com.raival.compose.file.explorer.screen.main.tab.home.data
+
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import kotlinx.serialization.Serializable
+
+fun getDefaultHomeLayout(minimalLayout: Boolean = false) = HomeLayout(
+ listOf(
+ HomeSectionConfig(
+ id = "recent_files",
+ type = HomeSectionType.RECENT_FILES,
+ title = globalClass.getString(R.string.recent_files),
+ isEnabled = !minimalLayout,
+ order = 0
+ ),
+ HomeSectionConfig(
+ id = "categories",
+ type = HomeSectionType.CATEGORIES,
+ title = globalClass.getString(R.string.categories),
+ isEnabled = !minimalLayout,
+ order = 1
+ ),
+ HomeSectionConfig(
+ id = "storage",
+ type = HomeSectionType.STORAGE,
+ title = globalClass.getString(R.string.storage),
+ isEnabled = true,
+ order = 2
+ ),
+ HomeSectionConfig(
+ id = "bookmarks",
+ type = HomeSectionType.BOOKMARKS,
+ title = globalClass.getString(R.string.bookmarks),
+ isEnabled = !minimalLayout,
+ order = 3
+ ),
+ HomeSectionConfig(
+ id = "recycle_bin",
+ type = HomeSectionType.RECYCLE_BIN,
+ title = globalClass.getString(R.string.recycle_bin),
+ isEnabled = !minimalLayout,
+ order = 4
+ ),
+ HomeSectionConfig(
+ id = "jump_to_path",
+ type = HomeSectionType.JUMP_TO_PATH,
+ title = globalClass.getString(R.string.jump_to_path),
+ isEnabled = !minimalLayout,
+ order = 5
+ )
+ )
+)
+
+@Serializable
+data class HomeLayout(
+ val sections: List
+)
+
+@Serializable
+data class HomeSectionConfig(
+ val id: String,
+ val type: HomeSectionType,
+ val title: String,
+ val isEnabled: Boolean,
+ var order: Int
+)
+
+@Serializable
+enum class HomeSectionType {
+ RECENT_FILES,
+ CATEGORIES,
+ STORAGE,
+ BOOKMARKS,
+ RECYCLE_BIN,
+ JUMP_TO_PATH
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategoryHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategory.kt
similarity index 86%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategoryHolder.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategory.kt
index f940f5b6..282fa971 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategoryHolder.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/HomeCategory.kt
@@ -2,7 +2,7 @@ package com.raival.compose.file.explorer.screen.main.tab.home.holder
import androidx.compose.ui.graphics.vector.ImageVector
-data class HomeCategoryHolder(
+data class HomeCategory(
val name: String,
val icon: ImageVector,
val onClick: () -> Unit
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFileHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFile.kt
similarity index 64%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFileHolder.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFile.kt
index e3389b68..587cd4b7 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFileHolder.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/holder/RecentFile.kt
@@ -1,11 +1,11 @@
package com.raival.compose.file.explorer.screen.main.tab.home.holder
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
import java.io.File
-data class RecentFileHolder(
+data class RecentFile(
val name: String,
val path: String,
val lastModified: Long,
- val documentHolder: DocumentHolder = DocumentHolder.fromFile(File(path))
+ val file: LocalFileHolder = LocalFileHolder(File(path))
)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt
new file mode 100644
index 00000000..b0cbeccb
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeLayoutSettingsScreen.kt
@@ -0,0 +1,295 @@
+package com.raival.compose.file.explorer.screen.main.tab.home.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.DragHandle
+import androidx.compose.material.icons.filled.RestartAlt
+import androidx.compose.material.icons.rounded.ArrowOutward
+import androidx.compose.material.icons.rounded.Bookmarks
+import androidx.compose.material.icons.rounded.Category
+import androidx.compose.material.icons.rounded.DeleteSweep
+import androidx.compose.material.icons.rounded.History
+import androidx.compose.material.icons.rounded.Storage
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.google.accompanist.systemuicontroller.rememberSystemUiController
+import com.google.gson.Gson
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeLayout
+import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionConfig
+import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionType
+import com.raival.compose.file.explorer.screen.main.tab.home.data.getDefaultHomeLayout
+import kotlinx.coroutines.launch
+import sh.calvin.reorderable.ReorderableCollectionItemScope
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeLayoutSettingsScreen(
+ onBackClick: (List) -> Unit
+) {
+ val useDarkIcons = !isSystemInDarkTheme()
+ val coroutineScope = rememberCoroutineScope()
+ val sections = remember { mutableStateListOf() }
+ val lazyListState = rememberLazyListState()
+ val reorderableState = rememberReorderableLazyListState(
+ lazyListState = lazyListState,
+ onMove = { from, to ->
+ sections.add(
+ to.index,
+ sections.removeAt(from.index)
+ )
+ }
+ )
+ Dialog(
+ onDismissRequest = { onBackClick(sections) },
+ properties = DialogProperties(
+ dismissOnClickOutside = false,
+ decorFitsSystemWindows = false,
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ val color = MaterialTheme.colorScheme.surfaceContainerHigh
+ val systemUiController = rememberSystemUiController()
+ DisposableEffect(systemUiController, useDarkIcons) {
+ systemUiController.setStatusBarColor(color = color, darkIcons = useDarkIcons)
+ onDispose {}
+ }
+
+ LaunchedEffect(Unit) {
+ val config = try {
+ Gson().fromJson(
+ globalClass.preferencesManager.appearancePrefs.homeTabLayout,
+ HomeLayout::class.java
+ )
+ } catch (e: Exception) {
+ logger.logError(e)
+ getDefaultHomeLayout()
+ }.sections.sortedBy { it.order }
+
+ sections.addAll(config)
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Text(
+ text = stringResource(R.string.customize_home_layout),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold
+ )
+ },
+ navigationIcon = {
+ IconButton(onClick = { onBackClick(sections) }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null
+ )
+ }
+ },
+ actions = {
+ IconButton(
+ onClick = {
+ coroutineScope.launch {
+ sections.clear()
+ sections.addAll(getDefaultHomeLayout().sections)
+ }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.RestartAlt,
+ contentDescription = null
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ titleContentColor = MaterialTheme.colorScheme.onSurface
+ )
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .padding(vertical = 16.dp)
+ ) {
+ // Sections list
+ LazyColumn(
+ state = lazyListState,
+ modifier = Modifier
+ .fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ items(sections, key = { it.id }) { section ->
+ ReorderableItem(
+ state = reorderableState,
+ key = section.id
+ ) { isDragging ->
+ HomeSectionItem(
+ reorderableScope = this,
+ section = section,
+ isDragging = isDragging,
+ onToggle = { enabled ->
+ val index = sections.indexOf(section)
+ sections[index] = section.copy(isEnabled = enabled)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HomeSectionItem(
+ reorderableScope: ReorderableCollectionItemScope,
+ section: HomeSectionConfig,
+ isDragging: Boolean,
+ onToggle: (Boolean) -> Unit
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .clip(RoundedCornerShape(12.dp)),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isDragging)
+ MaterialTheme.colorScheme.surfaceVariant
+ else
+ MaterialTheme.colorScheme.surface
+ ),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = if (isDragging) 8.dp else 2.dp
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Section icon
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(12.dp)
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = section.type.getIcon(),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ // Section info
+ Column(
+ modifier = Modifier.weight(1f)
+ ) {
+ Text(
+ text = section.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = section.type.getDescription(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ // Drag handle
+ Icon(
+ imageVector = Icons.Default.DragHandle,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = with(reorderableScope) {
+ Modifier
+ .padding(end = 8.dp)
+ .draggableHandle()
+ }
+ )
+
+ // Toggle switch
+ Switch(
+ checked = section.isEnabled,
+ onCheckedChange = onToggle
+ )
+ }
+ }
+}
+
+fun HomeSectionType.getIcon(): ImageVector {
+ return when (this) {
+ HomeSectionType.RECENT_FILES -> Icons.Rounded.History
+ HomeSectionType.CATEGORIES -> Icons.Rounded.Category
+ HomeSectionType.STORAGE -> Icons.Rounded.Storage
+ HomeSectionType.BOOKMARKS -> Icons.Rounded.Bookmarks
+ HomeSectionType.RECYCLE_BIN -> Icons.Rounded.DeleteSweep
+ HomeSectionType.JUMP_TO_PATH -> Icons.Rounded.ArrowOutward
+ }
+}
+
+fun HomeSectionType.getDescription(): String {
+ return when (this) {
+ HomeSectionType.RECENT_FILES -> globalClass.getString(R.string.recently_modified_files)
+ HomeSectionType.CATEGORIES -> globalClass.getString(R.string.quick_access_categories)
+ HomeSectionType.STORAGE -> globalClass.getString(R.string.storage_devices_and_locations)
+ HomeSectionType.BOOKMARKS -> globalClass.getString(R.string.bookmarked_files_and_folders)
+ HomeSectionType.RECYCLE_BIN -> globalClass.getString(R.string.deleted_files)
+ HomeSectionType.JUMP_TO_PATH -> globalClass.getString(R.string.quick_path_navigation)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt
index b7093a9b..71a29e68 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/tab/home/ui/HomeTabContentView.kt
@@ -31,6 +31,9 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -44,161 +47,168 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.cheonjaeung.compose.grid.SimpleGridCells
import com.cheonjaeung.compose.grid.VerticalGrid
+import com.google.gson.Gson
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.main.MainActivityManager
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
import com.raival.compose.file.explorer.screen.main.tab.files.coil.canUseCoil
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.BOOKMARKS
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.VirtualFileHolder.Companion.RECENT
import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider
import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab
+import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeLayout
+import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionConfig
+import com.raival.compose.file.explorer.screen.main.tab.home.data.HomeSectionType
+import com.raival.compose.file.explorer.screen.main.tab.home.data.getDefaultHomeLayout
import com.raival.compose.file.explorer.screen.main.ui.SimpleNewTabViewItem
import com.raival.compose.file.explorer.screen.main.ui.StorageDeviceView
+import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ColumnScope.HomeTabContentView(tab: HomeTab) {
- Column(modifier = Modifier
- .fillMaxSize()
- .verticalScroll(rememberScrollState())) {
- val mainActivityManager = globalClass.mainActivityManager
- val context = LocalContext.current
+ val mainActivityManager = globalClass.mainActivityManager
+ val scope = rememberCoroutineScope()
+ val enabledSections = remember { mutableStateListOf() }
- LaunchedEffect(tab.id) {
- tab.fetchRecentFiles()
+ LaunchedEffect(tab.id) {
+ tab.fetchRecentFiles()
+ val config = try {
+ Gson().fromJson(
+ globalClass.preferencesManager.appearancePrefs.homeTabLayout,
+ HomeLayout::class.java
+ )
+ } catch (e: Exception) {
+ logger.logError(e)
+ getDefaultHomeLayout()
+ }.sections.filter { it.isEnabled }.sortedBy { it.order }
+
+ enabledSections.addAll(config)
+ }
+
+ if (tab.showCustomizeHomeTabDialog) {
+ HomeLayoutSettingsScreen { sections ->
+ tab.showCustomizeHomeTabDialog = false
+ var isAllDisabled = false
+
+ // Prevent disabling all sections
+ if (sections.all { !it.isEnabled }) {
+ isAllDisabled = true
+ }
+
+ sections.forEachIndexed { index, config ->
+ config.order = index
+ }
+
+ enabledSections.apply {
+ clear()
+ if (isAllDisabled) {
+ addAll(getDefaultHomeLayout(true).sections.filter { it.isEnabled }
+ .sortedBy { it.order })
+ } else {
+ addAll(sections.filter { it.isEnabled }.sortedBy { it.order })
+ }
+
+ }
+
+ scope.launch {
+ if (isAllDisabled) {
+ globalClass.preferencesManager.appearancePrefs.homeTabLayout = Gson().toJson(
+ getDefaultHomeLayout(true)
+ )
+ } else {
+ globalClass.preferencesManager.appearancePrefs.homeTabLayout = Gson().toJson(
+ HomeLayout(sections)
+ )
+ }
+ }
}
+ }
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 12.dp),
- text = stringResource(R.string.recent_files),
- style = MaterialTheme.typography.titleMedium
- )
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ ) {
+ enabledSections.forEach { section ->
+ when (section.type) {
+ HomeSectionType.RECENT_FILES -> {
+ RecentFilesSection(tab = tab, mainActivityManager = mainActivityManager)
+ }
- if (tab.recentFileHolders.isEmpty()) {
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .clickable {
- mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.recentFiles)
- )
- },
- text = stringResource(R.string.no_recent_files),
- style = MaterialTheme.typography.bodyMedium,
- textAlign = TextAlign.Center
- )
- } else {
- LazyRow(
- modifier = Modifier
- .fillMaxWidth()
- .height(140.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- item { Space(6.dp) }
+ HomeSectionType.CATEGORIES -> {
+ CategoriesSection(tab = tab)
+ }
- items(tab.recentFileHolders, key = { it.path }) {
- Column(
- modifier = Modifier
- .size(110.dp, 140.dp)
- .padding(horizontal = 6.dp)
- .background(
- shape = RoundedCornerShape(8.dp),
- color = MaterialTheme.colorScheme.surfaceContainerLow
- )
- .border(
- width = 0.5.dp,
- color = MaterialTheme.colorScheme.surfaceContainerHighest,
- shape = RoundedCornerShape(8.dp)
- )
- .clip(RoundedCornerShape(8.dp))
- .combinedClickable(
- onClick = {
- it.documentHolder.openFile(context, false, false)
- },
- onLongClick = {
- mainActivityManager.addTabAndSelect(
- FilesTab(it.documentHolder)
- )
- }
- )
- ) {
- if (canUseCoil(it.documentHolder)) {
- AsyncImage(
- modifier = Modifier
- .fillMaxWidth()
- .weight(2f),
- model = it.documentHolder,
- contentDescription = null,
- contentScale = ContentScale.Crop,
- filterQuality = FilterQuality.Low
- )
- } else {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .weight(2f),
- contentAlignment = Alignment.Center
- ) {
- Image(
- modifier = Modifier.size(48.dp),
- painter = painterResource(id = it.documentHolder.getFileIconResource()),
- contentDescription = null
- )
- }
- }
+ HomeSectionType.STORAGE -> {
+ StorageSection(mainActivityManager = mainActivityManager)
+ }
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .weight(1f)
- .padding(8.dp),
- text = it.name,
- style = MaterialTheme.typography.labelSmall,
- maxLines = 2
- )
- }
+ HomeSectionType.BOOKMARKS -> {
+ BookmarksSection(mainActivityManager = mainActivityManager)
}
- item {
- TextButton(
- onClick = {
- mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.recentFiles)
- )
- }
- ) {
- Text(
- text = stringResource(R.string.more)
- )
- }
+ HomeSectionType.RECYCLE_BIN -> {
+ RecycleBinSection(mainActivityManager = mainActivityManager)
}
- item { Space(6.dp) }
+ HomeSectionType.JUMP_TO_PATH -> {
+ JumpToPathSection(mainActivityManager = mainActivityManager)
+ }
}
}
+ }
+}
+
+@Composable
+private fun RecentFilesSection(
+ tab: HomeTab,
+ mainActivityManager: MainActivityManager
+) {
+ val context = LocalContext.current
- Space(12.dp)
+ // Recent files
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ text = stringResource(R.string.recent_files),
+ style = MaterialTheme.typography.titleMedium
+ )
+ if (tab.recentFiles.isEmpty()) {
Text(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 12.dp),
- text = stringResource(R.string.categories),
- style = MaterialTheme.typography.titleMedium
+ .height(140.dp)
+ .padding(horizontal = 12.dp, vertical = 12.dp)
+ .clickable {
+ mainActivityManager.replaceCurrentTabWith(
+ FilesTab(VirtualFileHolder(RECENT))
+ )
+ },
+ text = stringResource(R.string.no_recent_files),
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center
)
-
- VerticalGrid(
+ } else {
+ LazyRow(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 12.dp),
- columns = SimpleGridCells.Fixed(3)
+ .height(140.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- tab.getMainCategories().forEach {
+ item { Space(6.dp) }
+
+ items(tab.recentFiles, key = { it.path }) {
Column(
- Modifier
- .padding(4.dp)
+ modifier = Modifier
+ .size(110.dp, 140.dp)
+ .padding(horizontal = 6.dp)
.background(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow
@@ -208,66 +218,184 @@ fun ColumnScope.HomeTabContentView(tab: HomeTab) {
color = MaterialTheme.colorScheme.surfaceContainerHighest,
shape = RoundedCornerShape(8.dp)
)
- .clickable { it.onClick() }
- .padding(8.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
+ .clip(RoundedCornerShape(8.dp))
+ .combinedClickable(
+ onClick = {
+ it.file.open(context, false, false, null)
+ },
+ onLongClick = {
+ mainActivityManager.replaceCurrentTabWith(
+ FilesTab(it.file)
+ )
+ }
+ )
) {
- Icon(
- modifier = Modifier.padding(8.dp),
- imageVector = it.icon,
- contentDescription = null
+ if (canUseCoil(it.file)) {
+ AsyncImage(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(2f),
+ model = it.file,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ filterQuality = FilterQuality.Low
+ )
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(2f),
+ contentAlignment = Alignment.Center
+ ) {
+ Image(
+ modifier = Modifier.size(48.dp),
+ painter = painterResource(id = it.file.iconPlaceholder),
+ contentDescription = null
+ )
+ }
+ }
+
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ .padding(8.dp),
+ text = it.name,
+ style = MaterialTheme.typography.labelSmall,
+ maxLines = 2
)
- Text(text = it.name)
}
}
- }
-
- Space(12.dp)
-
- Text(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 12.dp),
- text = stringResource(R.string.storage),
- style = MaterialTheme.typography.titleMedium
- )
-
- StorageProvider.getStorageDevices(globalClass).forEach {
- StorageDeviceView(storageDeviceHolder = it) {
- mainActivityManager.replaceCurrentTabWith(FilesTab(it.documentHolder))
+ item {
+ TextButton(
+ onClick = {
+ mainActivityManager.replaceCurrentTabWith(
+ FilesTab(VirtualFileHolder(RECENT))
+ )
+ }
+ ) {
+ Text(
+ text = stringResource(R.string.more)
+ )
+ }
}
- HorizontalDivider()
+ item { Space(6.dp) }
}
+ }
- if (globalClass.filesTabManager.bookmarks.isNotEmpty()) {
- SimpleNewTabViewItem(
- title = stringResource(R.string.bookmarks),
- imageVector = Icons.Rounded.Bookmarks
+ Space(12.dp)
+}
+
+@Composable
+private fun CategoriesSection(
+ tab: HomeTab
+) {
+ // Quick access tiles
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ text = stringResource(R.string.categories),
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ VerticalGrid(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ columns = SimpleGridCells.Fixed(3)
+ ) {
+ tab.getMainCategories().forEach {
+ Column(
+ Modifier
+ .padding(4.dp)
+ .background(
+ shape = RoundedCornerShape(8.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerLow
+ )
+ .border(
+ width = 0.5.dp,
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ shape = RoundedCornerShape(8.dp)
+ )
+ .clickable { it.onClick() }
+ .padding(8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
) {
- mainActivityManager.replaceCurrentTabWith(
- FilesTab(StorageProvider.bookmarks)
+ Icon(
+ modifier = Modifier.padding(8.dp),
+ imageVector = it.icon,
+ contentDescription = null
)
+ Text(text = it.name)
}
-
- HorizontalDivider()
}
+ }
- SimpleNewTabViewItem(
- title = stringResource(R.string.recycle_bin),
- imageVector = Icons.Rounded.DeleteSweep
- ) {
- mainActivityManager.replaceCurrentTabWith(FilesTab(globalClass.recycleBinDir))
- }
+ Space(12.dp)
+}
+@Composable
+private fun StorageSection(
+ mainActivityManager: MainActivityManager
+) {
+ // Storage options
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 12.dp),
+ text = stringResource(R.string.storage),
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ StorageProvider.getStorageDevices(globalClass).forEach {
+ StorageDeviceView(storageDevice = it) {
+ mainActivityManager.replaceCurrentTabWith(FilesTab(it.contentHolder))
+ }
HorizontalDivider()
+ }
+}
+@Composable
+private fun BookmarksSection(
+ mainActivityManager: MainActivityManager
+) {
+ if (globalClass.filesTabManager.bookmarks.isNotEmpty()) {
SimpleNewTabViewItem(
- title = stringResource(R.string.jump_to_path),
- imageVector = Icons.Rounded.ArrowOutward
+ title = stringResource(R.string.bookmarks),
+ imageVector = Icons.Rounded.Bookmarks
) {
- mainActivityManager.showJumpToPathDialog = true
+ mainActivityManager.replaceCurrentTabWith(
+ FilesTab(VirtualFileHolder(BOOKMARKS))
+ )
}
+ HorizontalDivider()
+ }
+}
+
+@Composable
+private fun RecycleBinSection(
+ mainActivityManager: MainActivityManager
+) {
+ SimpleNewTabViewItem(
+ title = stringResource(R.string.recycle_bin),
+ imageVector = Icons.Rounded.DeleteSweep
+ ) {
+ mainActivityManager.replaceCurrentTabWith(FilesTab(globalClass.recycleBinDir))
+ }
+ HorizontalDivider()
+}
+
+@Composable
+private fun JumpToPathSection(
+ mainActivityManager: MainActivityManager
+) {
+ SimpleNewTabViewItem(
+ title = stringResource(R.string.jump_to_path),
+ imageVector = Icons.Rounded.ArrowOutward
+ ) {
+ mainActivityManager.showJumpToPathDialog = true
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt
index 04ca396a..e1bf818c 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/AppInfoDialog.kt
@@ -1,7 +1,6 @@
package com.raival.compose.file.explorer.screen.main.ui
import android.content.Intent
-import android.net.Uri
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -16,10 +15,12 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
+import androidx.core.net.toUri
import coil3.compose.AsyncImage
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.ui.block
+import com.raival.compose.file.explorer.screen.logs.LogsActivity
@Composable
fun AppInfoDialog() {
@@ -66,7 +67,7 @@ fun AppInfoDialog() {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
- Uri.parse("https://github.com/Raival-e/File-Explorer-Compose")
+ "https://github.com/Raival-e/File-Explorer-Compose".toUri()
)
)
}
@@ -74,6 +75,17 @@ fun AppInfoDialog() {
Text(text = stringResource(R.string.github))
}
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ context.startActivity(
+ Intent(context, LogsActivity::class.java)
+ )
+ }
+ ) {
+ Text(text = stringResource(R.string.title_activity_logs))
+ }
+
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/DrawerContent.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/DrawerContent.kt
deleted file mode 100644
index c3315c09..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/DrawerContent.kt
+++ /dev/null
@@ -1,157 +0,0 @@
-package com.raival.compose.file.explorer.screen.main.ui
-
-import android.content.Intent
-import android.net.Uri
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.FolderOpen
-import androidx.compose.material.icons.rounded.Numbers
-import androidx.compose.material.icons.rounded.OpenInBrowser
-import androidx.compose.material.icons.rounded.SdStorage
-import androidx.compose.material.icons.rounded.Settings
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.LinearProgressIndicator
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.graphics.StrokeCap
-import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.platform.LocalContext
-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 androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.toFormattedSize
-import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.INTERNAL_STORAGE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.ROOT
-import com.raival.compose.file.explorer.screen.main.tab.files.provider.StorageProvider
-import com.raival.compose.file.explorer.screen.preferences.PreferencesActivity
-import kotlinx.coroutines.launch
-
-@Composable
-fun DrawerContent() {
- val configuration = LocalConfiguration.current
- val isLandscape = configuration.orientation == android.content.res.Configuration.ORIENTATION_LANDSCAPE
- val isTablet = configuration.screenWidthDp >= 600
- val drawerWidth = if(isLandscape || isTablet) 400.dp else configuration.screenWidthDp.dp / 4 * 3
- val coroutineScope = rememberCoroutineScope()
- val context = LocalContext.current
-
- Column(
- Modifier
- .fillMaxHeight()
- .width(drawerWidth)
- .background(color = MaterialTheme.colorScheme.surfaceContainerLowest)
- ) {
- Row(
- Modifier
- .fillMaxWidth()
- .height(64.dp)
- .background(color = MaterialTheme.colorScheme.surfaceContainer)
- .padding(8.dp)
- .padding(start = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(Modifier.weight(1f)) {
- Text(
- modifier = Modifier,
- text = stringResource(id = R.string.app_name),
- fontSize = 16.sp,
- maxLines = 1,
- fontWeight = FontWeight.Medium,
- lineHeight = 20.sp,
- overflow = TextOverflow.Ellipsis
- )
- Text(
- modifier = Modifier.alpha(0.7f),
- text = "https://github.com/raival",
- fontSize = 12.sp,
- maxLines = 1,
- lineHeight = 16.sp,
- overflow = TextOverflow.Ellipsis
- )
- }
-
- IconButton(onClick = {
- context.startActivity(
- Intent(
- Intent.ACTION_VIEW,
- Uri.parse("https://github.com/Raival-e/File-Explorer-Compose")
- )
- )
- }) {
- Icon(imageVector = Icons.Rounded.OpenInBrowser, contentDescription = null)
- }
-
- IconButton(onClick = {
- context.startActivity(Intent(context, PreferencesActivity::class.java))
- }) {
- Icon(imageVector = Icons.Rounded.Settings, contentDescription = null)
- }
- }
-
- StorageProvider.getStorageDevices(globalClass).forEach { storageDevice ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- globalClass.mainActivityManager.apply {
- coroutineScope.launch {
- drawerState.close()
- }
- addTabAndSelect(FilesTab(storageDevice.documentHolder))
- }
-
- }
- .padding(12.dp)
- .padding(end = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- modifier = Modifier.padding(8.dp),
- imageVector = when(storageDevice.type) {
- INTERNAL_STORAGE -> Icons.Rounded.FolderOpen
- ROOT -> Icons.Rounded.Numbers
- else -> Icons.Rounded.SdStorage
- },
- contentDescription = null
- )
- Space(size = 8.dp)
- Column {
- Text(text = storageDevice.title)
- LinearProgressIndicator(
- modifier = Modifier.height(8.dp),
- progress = { storageDevice.usedSize.toFloat() / storageDevice.totalSize },
- strokeCap = StrokeCap.Round
- )
- Text(
- modifier = Modifier
- .alpha(0.6f),
- text = "${storageDevice.usedSize.toFormattedSize()} used of ${storageDevice.totalSize.toFormattedSize()}",
- fontSize = 12.sp,
- textAlign = TextAlign.End
- )
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt
index 41a82d3c..3fe88821 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/JumpToPathDialog.kt
@@ -1,108 +1,156 @@
package com.raival.compose.file.explorer.screen.main.ui
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Cancel
import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.common.ui.block
import com.raival.compose.file.explorer.screen.main.tab.files.FilesTab
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import java.io.File
@Composable
fun JumpToPathDialog() {
val mainActivityManager = globalClass.mainActivityManager
if (mainActivityManager.showJumpToPathDialog) {
val context = LocalContext.current
-
var jumpToPathText by remember { mutableStateOf(emptyString) }
-
var isJumpToPathValid by remember { mutableStateOf(false) }
- Dialog(onDismissRequest = { mainActivityManager.showJumpToPathDialog = false }) {
- Column(
- modifier = Modifier
- .block()
- .padding(16.dp)
+ Dialog(
+ onDismissRequest = { mainActivityManager.showJumpToPathDialog = false },
+ ) {
+ Card(
+ shape = RoundedCornerShape(6.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
) {
- Text(
+ Column(
modifier = Modifier
- .fillMaxWidth(),
- text = stringResource(R.string.jump_to_path),
- fontSize = 18.sp,
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center
- )
-
- Space(size = 12.dp)
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.jump_to_path),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
- TextField(
- modifier = Modifier
- .fillMaxWidth(),
- value = jumpToPathText,
- onValueChange = {
- jumpToPathText = it
- isJumpToPathValid = FilesTab.isValidPath(it)
- },
- label = {
- Text(text = stringResource(R.string.destination_path))
- },
- trailingIcon = {
- AnimatedVisibility(visible = jumpToPathText.isNotEmpty()) {
- IconButton(
- onClick = { jumpToPathText = emptyString }
- ) {
- Icon(
- imageVector = Icons.Rounded.Cancel,
- contentDescription = null
- )
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = jumpToPathText,
+ onValueChange = {
+ jumpToPathText = it
+ isJumpToPathValid = FilesTab.isValidLocalPath(it)
+ },
+ label = { Text(text = stringResource(R.string.destination_path)) },
+ singleLine = true,
+ shape = RoundedCornerShape(6.dp),
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ trailingIcon = {
+ AnimatedVisibility(visible = jumpToPathText.isNotEmpty()) {
+ IconButton(
+ onClick = { jumpToPathText = emptyString }
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Cancel,
+ contentDescription = null
+ )
+ }
}
}
- }
- )
+ )
- Space(size = 8.dp)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ mainActivityManager.showJumpToPathDialog = false
+ },
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.cancel),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
- Button(
- onClick = {
- if (isJumpToPathValid) {
- mainActivityManager.jumpToFile(
- DocumentHolder.fromFullPath(jumpToPathText)!!,
- context
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ if (isJumpToPathValid) {
+ mainActivityManager.jumpToFile(
+ LocalFileHolder(
+ File(jumpToPathText)
+ ),
+ context
+ )
+ mainActivityManager.showJumpToPathDialog = false
+ }
+ },
+ enabled = jumpToPathText.isNotEmpty() && isJumpToPathValid,
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = if (isJumpToPathValid) stringResource(R.string.open) else stringResource(
+ R.string.invalid_path
+ ),
+ style = MaterialTheme.typography.labelLarge
)
- mainActivityManager.showJumpToPathDialog = false
}
- },
- enabled = isJumpToPathValid
- ) {
- Text(
- text = if (isJumpToPathValid) stringResource(R.string.open) else stringResource(
- R.string.invalid_path
- )
- )
+ }
}
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt
index 2f8c35a1..8dcee018 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/NewTabDialog.kt
@@ -39,8 +39,8 @@ fun NewTabDialog() {
)
StorageProvider.getStorageDevices(globalClass).forEach {
- StorageDeviceView(storageDeviceHolder = it) {
- globalClass.mainActivityManager.addTabAndSelect(FilesTab(it.documentHolder))
+ StorageDeviceView(storageDevice = it) {
+ globalClass.mainActivityManager.addTabAndSelect(FilesTab(it.contentHolder))
mainActivityManager.showNewTabDialog = false
}
HorizontalDivider()
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt
index 38433b18..3e4e2e47 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/StorageDeviceView.kt
@@ -23,13 +23,13 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.raival.compose.file.explorer.common.extension.toFormattedSize
import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDeviceHolder
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.INTERNAL_STORAGE
-import com.raival.compose.file.explorer.screen.main.tab.files.misc.ROOT
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.StorageDevice
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.INTERNAL_STORAGE
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.StorageDeviceType.ROOT
@Composable
fun StorageDeviceView(
- storageDeviceHolder: StorageDeviceHolder,
+ storageDevice: StorageDevice,
onClick: () -> Unit = {}
) {
Row(
@@ -44,7 +44,7 @@ fun StorageDeviceView(
) {
Icon(
modifier = Modifier.padding(8.dp),
- imageVector = when (storageDeviceHolder.type) {
+ imageVector = when (storageDevice.type) {
INTERNAL_STORAGE -> Icons.Rounded.FolderOpen
ROOT -> Icons.Rounded.Numbers
else -> Icons.Rounded.SdStorage
@@ -53,7 +53,7 @@ fun StorageDeviceView(
)
Space(size = 8.dp)
Column {
- Text(text = storageDeviceHolder.title)
+ Text(text = storageDevice.title)
Space(size = 8.dp)
Row(
Modifier.fillMaxWidth(),
@@ -63,7 +63,7 @@ fun StorageDeviceView(
modifier = Modifier
.weight(1f)
.height(8.dp),
- progress = { storageDeviceHolder.usedSize.toFloat() / storageDeviceHolder.totalSize },
+ progress = { storageDevice.usedSize.toFloat() / storageDevice.totalSize },
strokeCap = StrokeCap.Round
)
Space(size = 8.dp)
@@ -71,7 +71,7 @@ fun StorageDeviceView(
modifier = Modifier
.alpha(0.6f)
.weight(1f),
- text = "${storageDeviceHolder.usedSize.toFormattedSize()}/${storageDeviceHolder.totalSize.toFormattedSize()}",
+ text = "${storageDevice.usedSize.toFormattedSize()}/${storageDevice.totalSize.toFormattedSize()}",
fontSize = 12.sp,
textAlign = TextAlign.End
)
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt
index db676f5c..dd46df6f 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/TabLayout.kt
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material3.Icon
@@ -25,7 +24,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable
fun TabLayout() {
val mainActivityManager = globalClass.mainActivityManager
- val tabLayoutState = rememberLazyListState()
+ val tabLayoutState = globalClass.mainActivityManager.tabLayoutState
val reorderableLazyListState = rememberReorderableLazyListState(tabLayoutState) { from, to ->
if (from.index == mainActivityManager.selectedTabIndex) mainActivityManager.selectedTabIndex =
to.index
@@ -57,7 +56,7 @@ fun TabLayout() {
ReorderableItem(reorderableLazyListState, key = tab.id) { isDragging ->
TabHeaderView(
tab = tab,
- isSelected = mainActivityManager.tabs[mainActivityManager.selectedTabIndex] == tab,
+ isSelected = mainActivityManager.getActiveTab() == tab,
index = index,
this
)
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt
index 21f3d56c..5349604b 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/main/ui/ToolbarView.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.EditAttributes
import androidx.compose.material.icons.rounded.Menu
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Settings
@@ -34,6 +35,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.screen.main.tab.home.HomeTab
import com.raival.compose.file.explorer.screen.preferences.PreferencesActivity
@Composable
@@ -97,6 +99,23 @@ fun Toolbar() {
onDismissRequest = { showOptionsMenu = false },
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
+ if (mainActivityManager.getActiveTab() is HomeTab) {
+ DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.customize_home_tab)) },
+ onClick = {
+ (mainActivityManager.getActiveTab() as HomeTab).showCustomizeHomeTabDialog =
+ true
+ showOptionsMenu = false
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Rounded.EditAttributes,
+ contentDescription = null
+ )
+ }
+ )
+ }
+
DropdownMenuItem(
text = { Text(text = stringResource(R.string.preferences)) },
onClick = {
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt
index 67e166c6..3959fb33 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesActivity.kt
@@ -29,8 +29,10 @@ import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.base.BaseActivity
import com.raival.compose.file.explorer.common.ui.SafeSurface
import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.screen.preferences.ui.DisplayContainer
-import com.raival.compose.file.explorer.screen.preferences.ui.GeneralContainer
+import com.raival.compose.file.explorer.screen.preferences.ui.AppearanceContainer
+import com.raival.compose.file.explorer.screen.preferences.ui.BehaviorContainer
+import com.raival.compose.file.explorer.screen.preferences.ui.FileListContainer
+import com.raival.compose.file.explorer.screen.preferences.ui.FileOperationContainer
import com.raival.compose.file.explorer.screen.preferences.ui.SingleChoiceDialog
import com.raival.compose.file.explorer.screen.preferences.ui.TextEditorContainer
import com.raival.compose.file.explorer.theme.FileExplorerTheme
@@ -79,10 +81,17 @@ class PreferencesActivity : BaseActivity() {
Column(
Modifier
.fillMaxSize()
- .verticalScroll(rememberScrollState())) {
+ .verticalScroll(rememberScrollState())
+ ) {
+ Space(size = 4.dp)
+ AppearanceContainer()
+ Space(size = 4.dp)
+ FileListContainer()
+ Space(size = 4.dp)
+ FileOperationContainer()
+ Space(size = 4.dp)
+ BehaviorContainer()
Space(size = 4.dp)
- DisplayContainer()
- GeneralContainer()
TextEditorContainer()
Space(size = 4.dp)
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt
index c9852728..dc0288a5 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/PreferencesManager.kt
@@ -7,80 +7,60 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
+import com.google.gson.Gson
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.extension.fromJson
import com.raival.compose.file.explorer.common.extension.toJson
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.ContentHolder
import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileSortingPrefs
import com.raival.compose.file.explorer.screen.main.tab.files.misc.SortingMethod.SORT_BY_NAME
+import com.raival.compose.file.explorer.screen.main.tab.home.data.getDefaultHomeLayout
import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize
import com.raival.compose.file.explorer.screen.preferences.constant.ThemePreference
-import com.raival.compose.file.explorer.screen.preferences.constant.dataStore
+import com.raival.compose.file.explorer.screen.preferences.misc.prefDataStore
import com.raival.compose.file.explorer.screen.preferences.misc.prefMutableState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
class PreferencesManager {
- object SingleChoiceDialog {
- var show by mutableStateOf(false)
-
- var title = emptyString
- private set
- var description = emptyString
- private set
- var choices = mutableListOf()
- private set
- var onSelect: (choice: Int) -> Unit = {}
- private set
- var selectedChoice = -1
-
- fun dismiss() {
- show = false
- title = emptyString
- description = emptyString
- choices.clear()
- selectedChoice = -1
- onSelect = {}
- }
+ val singleChoiceDialog = SingleChoiceDialog()
- fun show(
- title: String,
- description: String,
- choices: List,
- selectedChoice: Int,
- onSelect: (choice: Int) -> Unit
- ) {
- SingleChoiceDialog.title = title
- SingleChoiceDialog.description = description
- SingleChoiceDialog.choices.clear()
- SingleChoiceDialog.choices.addAll(choices)
- SingleChoiceDialog.onSelect = onSelect
- SingleChoiceDialog.selectedChoice = selectedChoice
- show = true
- }
- }
+ val appearancePrefs = AppearancePrefs()
+ val fileListPrefs = FileListPrefs()
+ val fileOperationPrefs = FileOperationPrefs()
+ val behaviorPrefs = BehaviorPrefs()
+ val textEditorPrefs = TextEditorPrefs()
+ val filesSortingPrefs = FilesSortingPrefs()
- object DisplayPrefs {
+ class AppearancePrefs {
var theme by prefMutableState(
keyName = "theme",
defaultValue = ThemePreference.SYSTEM.ordinal,
getPreferencesKey = { intPreferencesKey(it) }
)
- var fileListSize by prefMutableState(
- keyName = "fileListSize",
- defaultValue = FilesTabFileListSize.LARGE.ordinal,
- getPreferencesKey = { intPreferencesKey(it) }
- )
-
var showBottomBarLabels by prefMutableState(
keyName = "showBottomBarLabels",
defaultValue = true,
getPreferencesKey = { booleanPreferencesKey(it) }
)
- var fileListColumnCount by prefMutableState(
+ var homeTabLayout by prefMutableState(
+ keyName = "homeTabLayout",
+ defaultValue = Gson().toJson(getDefaultHomeLayout()),
+ getPreferencesKey = { stringPreferencesKey("homeTabLayout") }
+ )
+ }
+
+ class FileListPrefs {
+ var itemSize by prefMutableState(
+ keyName = "fileListSize",
+ defaultValue = FilesTabFileListSize.MEDIUM.ordinal,
+ getPreferencesKey = { intPreferencesKey(it) }
+ )
+
+ var columnCount by prefMutableState(
keyName = "fileListColumnCount",
defaultValue = 1,
getPreferencesKey = { intPreferencesKey(it) }
@@ -99,7 +79,47 @@ class PreferencesManager {
)
}
- object TextEditorPrefs {
+ class BehaviorPrefs {
+ var showFileOptionMenuOnLongClick by prefMutableState(
+ keyName = "showFileOptionMenuOnLongClick",
+ defaultValue = true,
+ getPreferencesKey = { booleanPreferencesKey(it) }
+ )
+
+ var disablePullDownToRefresh by prefMutableState(
+ keyName = "disablePullDownToRefresh",
+ defaultValue = true,
+ getPreferencesKey = { booleanPreferencesKey(it) }
+ )
+
+ var skipHomeWhenTabClosed by prefMutableState(
+ keyName = "skipHomeWhenTabClosed",
+ defaultValue = false,
+ getPreferencesKey = { booleanPreferencesKey(it) }
+ )
+ }
+
+ class FileOperationPrefs {
+ var searchInFilesLimit by prefMutableState(
+ keyName = "searchInFilesLimit",
+ defaultValue = 150,
+ getPreferencesKey = { intPreferencesKey(it) }
+ )
+
+ var signMergedApkBundleFiles by prefMutableState(
+ keyName = "signMergedApkBundleFiles",
+ defaultValue = true,
+ getPreferencesKey = { booleanPreferencesKey(it) }
+ )
+
+ var moveToRecycleBin by prefMutableState(
+ keyName = "moveToRecycleBin",
+ defaultValue = true,
+ getPreferencesKey = { booleanPreferencesKey(it) }
+ )
+ }
+
+ class TextEditorPrefs {
var pinLineNumber by prefMutableState(
keyName = "pinLineNumber",
defaultValue = true,
@@ -161,34 +181,8 @@ class PreferencesManager {
)
}
- object GeneralPrefs {
- var searchInFilesLimit by prefMutableState(
- keyName = "searchInFilesLimit",
- defaultValue = 150,
- getPreferencesKey = { intPreferencesKey(it) }
- )
-
- var showFileOptionMenuOnLongClick by prefMutableState(
- keyName = "showFileOptionMenuOnLongClick",
- defaultValue = true,
- getPreferencesKey = { booleanPreferencesKey(it) }
- )
-
- var moveToRecycleBin by prefMutableState(
- keyName = "moveToRecycleBin",
- defaultValue = true,
- getPreferencesKey = { booleanPreferencesKey(it) }
- )
-
- var signApk by prefMutableState(
- keyName = "signApk",
- defaultValue = false,
- getPreferencesKey = { booleanPreferencesKey(it) }
- )
- }
-
- object FilesSortingPrefs {
- var filesSortingMethod by prefMutableState(
+ class FilesSortingPrefs {
+ var defaultSortMethod by prefMutableState(
keyName = "filesSortingMethod",
defaultValue = SORT_BY_NAME,
getPreferencesKey = { intPreferencesKey(it) }
@@ -200,36 +194,70 @@ class PreferencesManager {
getPreferencesKey = { booleanPreferencesKey(it) }
)
- var reverseFilesSortingMethod by prefMutableState(
+ var reverse by prefMutableState(
keyName = "reverseFilesSortingMethod",
defaultValue = false,
getPreferencesKey = { booleanPreferencesKey(it) }
)
- fun getSortingPrefsFor(doc: DocumentHolder): FileSortingPrefs {
+ fun getSortingPrefsFor(content: ContentHolder): FileSortingPrefs {
return runBlocking {
fromJson(
- globalClass.dataStore.data.first()[stringPreferencesKey("fileSortingPrefs_${doc.path}")]
+ globalClass.prefDataStore.data.first()[stringPreferencesKey("fileSortingPrefs_${content.uniquePath}")]
) ?: FileSortingPrefs(
- sortMethod = filesSortingMethod,
+ sortMethod = defaultSortMethod,
showFoldersFirst = showFoldersFirst,
- reverseSorting = reverseFilesSortingMethod
+ reverseSorting = reverse
)
}
}
- fun setSortingPrefsFor(doc: DocumentHolder, prefs: FileSortingPrefs) {
+ fun setSortingPrefsFor(content: ContentHolder, prefs: FileSortingPrefs) {
runBlocking {
- globalClass.dataStore.edit {
- it[stringPreferencesKey("fileSortingPrefs_${doc.path}")] = prefs.toJson()
+ globalClass.prefDataStore.edit {
+ it[stringPreferencesKey("fileSortingPrefs_${content.uniquePath}")] =
+ prefs.toJson()
}
}
}
}
- val singleChoiceDialog = SingleChoiceDialog
- val displayPrefs = DisplayPrefs
- val textEditorPrefs = TextEditorPrefs
- val generalPrefs = GeneralPrefs
- val filesSortingPrefs = FilesSortingPrefs
+ class SingleChoiceDialog {
+ var show by mutableStateOf(false)
+
+ var title = emptyString
+ private set
+ var description = emptyString
+ private set
+ var choices = mutableListOf()
+ private set
+ var onSelect: (choice: Int) -> Unit = {}
+ private set
+ var selectedChoice = -1
+
+ fun dismiss() {
+ show = false
+ title = emptyString
+ description = emptyString
+ choices.clear()
+ selectedChoice = -1
+ onSelect = {}
+ }
+
+ fun show(
+ title: String,
+ description: String,
+ choices: List,
+ selectedChoice: Int,
+ onSelect: (choice: Int) -> Unit
+ ) {
+ this.title = title
+ this.description = description
+ this.choices.clear()
+ this.choices.addAll(choices)
+ this.onSelect = onSelect
+ this.selectedChoice = selectedChoice
+ show = true
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt
index 1c74b9e2..7da77f61 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSize.kt
@@ -1,6 +1,7 @@
package com.raival.compose.file.explorer.screen.preferences.constant
enum class FilesTabFileListSize {
+ EXTRA_SMALL,
SMALL,
MEDIUM,
LARGE,
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt
index b3428d94..59d8a86f 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/FilesTabFileListSizeMap.kt
@@ -1,7 +1,10 @@
package com.raival.compose.file.explorer.screen.preferences.constant
+import com.raival.compose.file.explorer.App.Companion.globalClass
+
object FilesTabFileListSizeMap {
object IconSize {
+ const val EXTRA_SMALL = 32
const val SMALL = 42
const val MEDIUM = 46
const val LARGE = 48
@@ -9,9 +12,31 @@ object FilesTabFileListSizeMap {
}
object FontSize {
+ const val EXTRA_SMALL = 12
const val SMALL = 15
const val MEDIUM = 18
const val LARGE = 20
const val EXTRA_LARGE = 22
}
+
+ fun getFileListSpace() = when (globalClass.preferencesManager.fileListPrefs.itemSize) {
+ FilesTabFileListSize.LARGE.ordinal, FilesTabFileListSize.EXTRA_LARGE.ordinal -> 8
+ else -> 4
+ }
+
+ fun getFileListIconSize() = when (globalClass.preferencesManager.fileListPrefs.itemSize) {
+ FilesTabFileListSize.EXTRA_SMALL.ordinal -> IconSize.EXTRA_SMALL
+ FilesTabFileListSize.SMALL.ordinal -> IconSize.SMALL
+ FilesTabFileListSize.MEDIUM.ordinal -> IconSize.MEDIUM
+ FilesTabFileListSize.LARGE.ordinal -> IconSize.LARGE
+ else -> IconSize.EXTRA_LARGE
+ }
+
+ fun getFileListFontSize() = when (globalClass.preferencesManager.fileListPrefs.itemSize) {
+ FilesTabFileListSize.EXTRA_SMALL.ordinal -> FontSize.EXTRA_SMALL
+ FilesTabFileListSize.SMALL.ordinal -> FontSize.SMALL
+ FilesTabFileListSize.MEDIUM.ordinal -> FontSize.MEDIUM
+ FilesTabFileListSize.LARGE.ordinal -> FontSize.LARGE
+ else -> FontSize.EXTRA_LARGE
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/DataStore.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/DataStore.kt
similarity index 54%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/DataStore.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/DataStore.kt
index fb31a438..a2efe698 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/constant/DataStore.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/DataStore.kt
@@ -1,8 +1,8 @@
-package com.raival.compose.file.explorer.screen.preferences.constant
+package com.raival.compose.file.explorer.screen.preferences.misc
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
-val Context.dataStore: DataStore by preferencesDataStore(name = "preferences")
\ No newline at end of file
+val Context.prefDataStore: DataStore by preferencesDataStore(name = "preferences")
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt
index 694f42eb..1e89b6c8 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/misc/PrefMutableState.kt
@@ -5,7 +5,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import com.raival.compose.file.explorer.App
-import com.raival.compose.file.explorer.screen.preferences.constant.dataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
@@ -17,7 +16,7 @@ inline fun prefMutableState(
val key: Preferences.Key = getPreferencesKey(keyName)
val snapshotMutableState: MutableState = mutableStateOf(
runBlocking {
- App.globalClass.dataStore.data.first()[key] ?: defaultValue
+ App.globalClass.prefDataStore.data.first()[key] ?: defaultValue
}
)
@@ -29,14 +28,14 @@ inline fun prefMutableState(
snapshotMutableState.value = value
runBlocking {
try {
- App.globalClass.dataStore.edit {
+ App.globalClass.prefDataStore.edit {
if (value != null) {
it[key] = value as A
} else {
it.remove(key)
}
}
- } catch (e: Exception) {
+ } catch (_: Exception) {
snapshotMutableState.value = rollbackValue
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AppearanceContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AppearanceContainer.kt
new file mode 100644
index 00000000..ad9608b7
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/AppearanceContainer.kt
@@ -0,0 +1,58 @@
+package com.raival.compose.file.explorer.screen.preferences.ui
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.Label
+import androidx.compose.material.icons.rounded.Nightlight
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.screen.preferences.constant.ThemePreference
+
+@Composable
+fun AppearanceContainer() {
+ val manager = globalClass.preferencesManager
+ val appearancePrefs = manager.appearancePrefs
+
+ Container(title = stringResource(R.string.appearance)) {
+ PreferenceItem(
+ label = stringResource(R.string.theme),
+ supportingText = when (appearancePrefs.theme) {
+ ThemePreference.LIGHT.ordinal -> stringResource(R.string.light)
+ ThemePreference.DARK.ordinal -> stringResource(R.string.dark)
+ else -> stringResource(R.string.follow_system)
+ },
+ icon = Icons.Rounded.Nightlight,
+ onClick = {
+ manager.singleChoiceDialog.show(
+ title = globalClass.getString(R.string.theme),
+ description = globalClass.getString(R.string.select_theme_preference),
+ choices = listOf(
+ globalClass.getString(R.string.light),
+ globalClass.getString(R.string.dark),
+ globalClass.getString(R.string.follow_system)
+ ),
+ selectedChoice = appearancePrefs.theme,
+ onSelect = { appearancePrefs.theme = it }
+ )
+ }
+ )
+
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
+ PreferenceItem(
+ label = stringResource(R.string.show_bottom_bar_labels),
+ supportingText = emptyString,
+ icon = Icons.AutoMirrored.Rounded.Label,
+ switchState = appearancePrefs.showBottomBarLabels,
+ onSwitchChange = { appearancePrefs.showBottomBarLabels = it }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/BehaviorContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/BehaviorContainer.kt
new file mode 100644
index 00000000..77f1644f
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/BehaviorContainer.kt
@@ -0,0 +1,55 @@
+package com.raival.compose.file.explorer.screen.preferences.ui
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Refresh
+import androidx.compose.material.icons.rounded.TouchApp
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+
+@Composable
+fun BehaviorContainer() {
+ val manager = globalClass.preferencesManager
+ val behaviorPrefs = manager.behaviorPrefs
+
+ Container(title = stringResource(R.string.behavior)) {
+ PreferenceItem(
+ label = stringResource(R.string.show_files_options_menu_on_long_click),
+ supportingText = emptyString,
+ icon = Icons.Rounded.TouchApp,
+ switchState = behaviorPrefs.showFileOptionMenuOnLongClick,
+ onSwitchChange = { behaviorPrefs.showFileOptionMenuOnLongClick = it }
+ )
+
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
+ PreferenceItem(
+ label = stringResource(R.string.disable_pull_down_to_refresh),
+ supportingText = emptyString,
+ icon = Icons.Rounded.Refresh,
+ switchState = behaviorPrefs.disablePullDownToRefresh,
+ onSwitchChange = { behaviorPrefs.disablePullDownToRefresh = it }
+ )
+
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
+ PreferenceItem(
+ label = stringResource(R.string.skip_home_when_tab_closed),
+ supportingText = emptyString,
+ icon = Icons.Rounded.TouchApp,
+ switchState = behaviorPrefs.skipHomeWhenTabClosed,
+ onSwitchChange = { behaviorPrefs.skipHomeWhenTabClosed = it }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt
index 7d3a2a70..e9d06929 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/Container.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
@@ -31,15 +32,16 @@ fun Container(
title: String,
content: @Composable () -> Unit
) {
- var expanded by remember { mutableStateOf(false) }
+ var expanded by remember { mutableStateOf(true) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.block(
- color = MaterialTheme.colorScheme.surface,
- borderSize = 0.dp
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ borderSize = 0.dp,
+ shape = RoundedCornerShape(6.dp)
)
.padding(12.dp)
) {
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/DisplayContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileListContainer.kt
similarity index 55%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/DisplayContainer.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileListContainer.kt
index 5e2501dc..1dd9791b 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/DisplayContainer.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileListContainer.kt
@@ -1,52 +1,30 @@
package com.raival.compose.file.explorer.screen.preferences.ui
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.rounded.Label
import androidx.compose.material.icons.automirrored.rounded.ManageSearch
import androidx.compose.material.icons.rounded.Height
import androidx.compose.material.icons.rounded.HideSource
-import androidx.compose.material.icons.rounded.Nightlight
import androidx.compose.material.icons.rounded.Numbers
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.screen.preferences.constant.FilesTabFileListSize
-import com.raival.compose.file.explorer.screen.preferences.constant.ThemePreference
@Composable
-fun DisplayContainer() {
+fun FileListContainer() {
val manager = globalClass.preferencesManager
- val preferences = manager.displayPrefs
-
- Container(title = stringResource(R.string.display)) {
- PreferenceItem(
- label = stringResource(R.string.theme),
- supportingText = when (preferences.theme) {
- ThemePreference.LIGHT.ordinal -> stringResource(R.string.light)
- ThemePreference.DARK.ordinal -> stringResource(R.string.dark)
- else -> stringResource(R.string.follow_system)
- },
- icon = Icons.Rounded.Nightlight,
- onClick = {
- manager.singleChoiceDialog.show(
- title = globalClass.getString(R.string.theme),
- description = globalClass.getString(R.string.select_theme_preference),
- choices = listOf(
- globalClass.getString(R.string.light),
- globalClass.getString(R.string.dark),
- globalClass.getString(R.string.follow_system)
- ),
- selectedChoice = preferences.theme,
- onSelect = { preferences.theme = it }
- )
- }
- )
+ val fileListPrefs = manager.fileListPrefs
+ Container(title = stringResource(R.string.file_list)) {
PreferenceItem(
label = stringResource(R.string.file_list_size),
- supportingText = when (preferences.fileListSize) {
+ supportingText = when (fileListPrefs.itemSize) {
+ FilesTabFileListSize.EXTRA_SMALL.ordinal -> stringResource(R.string.extra_small)
FilesTabFileListSize.SMALL.ordinal -> stringResource(R.string.small)
FilesTabFileListSize.MEDIUM.ordinal -> stringResource(R.string.medium)
FilesTabFileListSize.LARGE.ordinal -> stringResource(R.string.large)
@@ -58,66 +36,74 @@ fun DisplayContainer() {
title = globalClass.getString(R.string.file_list_size),
description = globalClass.getString(R.string.file_list_size_desc),
choices = listOf(
+ globalClass.getString(R.string.extra_small),
globalClass.getString(R.string.small),
globalClass.getString(R.string.medium),
globalClass.getString(R.string.large),
globalClass.getString(R.string.extra_large)
),
- selectedChoice = preferences.fileListSize,
- onSelect = { preferences.fileListSize = it }
+ selectedChoice = fileListPrefs.itemSize,
+ onSelect = { fileListPrefs.itemSize = it }
)
}
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
val columnCount = arrayListOf(
"1", "2", "3", "4", "Auto"
)
PreferenceItem(
label = stringResource(R.string.files_list_column_count),
- supportingText = if (preferences.fileListColumnCount == -1) columnCount[4] else preferences.fileListColumnCount.toString(),
+ supportingText = if (fileListPrefs.columnCount == -1) columnCount[4] else fileListPrefs.columnCount.toString(),
icon = Icons.AutoMirrored.Rounded.ManageSearch,
onClick = {
manager.singleChoiceDialog.show(
title = globalClass.getString(R.string.files_list_column_count),
description = globalClass.getString(R.string.choose_number_of_columns),
choices = columnCount,
- selectedChoice = if (preferences.fileListColumnCount == -1) 4 else columnCount.indexOf(
- preferences.fileListColumnCount.toString()
+ selectedChoice = if (fileListPrefs.columnCount == -1) 4 else columnCount.indexOf(
+ fileListPrefs.columnCount.toString()
),
onSelect = {
val limit = when (columnCount[it]) {
columnCount[4] -> -1
else -> columnCount[it].toIntOrNull() ?: -1
}
- preferences.fileListColumnCount = limit
+ fileListPrefs.columnCount = limit
}
)
}
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(R.string.show_hidden_files),
supportingText = emptyString,
icon = Icons.Rounded.HideSource,
- switchState = preferences.showHiddenFiles,
- onSwitchChange = { preferences.showHiddenFiles = it }
+ switchState = fileListPrefs.showHiddenFiles,
+ onSwitchChange = { fileListPrefs.showHiddenFiles = it }
)
- PreferenceItem(
- label = stringResource(R.string.show_folder_s_content_count),
- supportingText = emptyString,
- icon = Icons.Rounded.Numbers,
- switchState = preferences.showFolderContentCount,
- onSwitchChange = { preferences.showFolderContentCount = it }
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
)
PreferenceItem(
- label = stringResource(R.string.show_bottom_bar_labels),
+ label = stringResource(R.string.show_folder_s_content_count),
supportingText = emptyString,
- icon = Icons.AutoMirrored.Rounded.Label,
- switchState = preferences.showBottomBarLabels,
- onSwitchChange = { preferences.showBottomBarLabels = it }
+ icon = Icons.Rounded.Numbers,
+ switchState = fileListPrefs.showFolderContentCount,
+ onSwitchChange = { fileListPrefs.showFolderContentCount = it }
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/GeneralContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileOperationContaner.kt
similarity index 63%
rename from app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/GeneralContainer.kt
rename to app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileOperationContaner.kt
index ebf929fa..81805985 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/GeneralContainer.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/FileOperationContaner.kt
@@ -2,22 +2,22 @@ package com.raival.compose.file.explorer.screen.preferences.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ManageSearch
-import androidx.compose.material.icons.rounded.Android
+import androidx.compose.material.icons.rounded.Key
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
@Composable
-fun GeneralContainer() {
+fun FileOperationContainer() {
val manager = globalClass.preferencesManager
- val preferences = manager.generalPrefs
+ val preferences = manager.fileOperationPrefs
+ val limits = arrayListOf("15", "25", "50", "100", stringResource(R.string.unlimited))
- val limits = arrayListOf(
- "15", "25", "50", "100", "Unlimited"
- )
-
- Container(title = stringResource(R.string.general)) {
+ Container(title = stringResource(R.string.file_operation)) {
PreferenceItem(
label = stringResource(R.string.search_in_files_limit),
supportingText = if (preferences.searchInFilesLimit == -1) limits[4] else preferences.searchInFilesLimit.toString(),
@@ -41,12 +41,17 @@ fun GeneralContainer() {
}
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
- label = stringResource(R.string.sign_apk),
- supportingText = globalClass.getString(R.string.sign_apk_desc),
- icon = Icons.Rounded.Android,
- switchState = preferences.signApk,
- onSwitchChange = { preferences.signApk = it }
+ label = stringResource(R.string.auto_sign_merged_apk_bundle_files),
+ supportingText = stringResource(R.string.auto_sign_merged_apk_bundle_files_description),
+ icon = Icons.Rounded.Key,
+ switchState = preferences.signMergedApkBundleFiles,
+ onSwitchChange = { preferences.signMergedApkBundleFiles = it }
)
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt
index fe504721..dd3a486a 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/preferences/ui/TextEditorContainer.kt
@@ -9,8 +9,11 @@ import androidx.compose.material.icons.rounded.Numbers
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.SpaceBar
import androidx.compose.material.icons.rounded.TextRotationNone
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
@@ -47,6 +50,11 @@ fun TextEditorContainer() {
}
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(id = R.string.pin_numbers_line),
icon = Icons.Rounded.Numbers,
@@ -54,6 +62,11 @@ fun TextEditorContainer() {
onSwitchChange = { preferences.pinLineNumber = it }
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(id = R.string.auto_symbol_pair),
icon = Icons.Rounded.Code,
@@ -61,6 +74,11 @@ fun TextEditorContainer() {
onSwitchChange = { preferences.symbolPairAutoCompletion = it }
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(id = R.string.auto_indentation),
icon = Icons.AutoMirrored.Rounded.KeyboardTab,
@@ -68,6 +86,11 @@ fun TextEditorContainer() {
onSwitchChange = { preferences.autoIndent = it }
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(id = R.string.magnifier),
icon = Icons.Rounded.Search,
@@ -75,6 +98,11 @@ fun TextEditorContainer() {
onSwitchChange = { preferences.enableMagnifier = it }
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(id = R.string.use_icu_selection),
icon = Icons.Rounded.TextRotationNone,
@@ -82,6 +110,11 @@ fun TextEditorContainer() {
onSwitchChange = { preferences.useICULibToSelectWords = it }
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(id = R.string.delete_empty_lines),
icon = Icons.Rounded.DeleteSweep,
@@ -89,6 +122,11 @@ fun TextEditorContainer() {
onSwitchChange = { preferences.deleteEmptyLineFast = it }
)
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ thickness = 3.dp
+ )
+
PreferenceItem(
label = stringResource(id = R.string.delete_tabs),
icon = Icons.Rounded.SpaceBar,
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt
index 37f52102..d489d9c1 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorActivity.kt
@@ -6,9 +6,15 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.Folder
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import com.raival.compose.file.explorer.App.Companion.globalClass
@@ -82,16 +88,39 @@ class TextEditorActivity : BaseActivity() {
)
}
- WarningDialog()
- JumpToPositionDialog(codeEditor)
+ if (textEditorManager.warningDialogProperties.showWarningDialog) {
+ WarningDialog(textEditorManager.warningDialogProperties)
+ }
+
+ if (textEditorManager.showJumpToPositionDialog) {
+ JumpToPositionDialog(codeEditor) {
+ textEditorManager.showJumpToPositionDialog = false
+ }
+ }
+
RecentFilesDialog(codeEditor)
ToolbarView(codeEditor, onBackPressedDispatcher)
HorizontalDivider()
- InfoBar()
+ InfoBar(textEditorManager.activitySubtitle)
CodeEditorView(codeEditor)
HorizontalDivider()
- BottomBarView(codeEditor)
- SearchPanel(codeEditor)
+ BottomBarView(codeEditor, textEditorManager.getSymbols(true)) {
+ IconButton(onClick = {
+ textEditorManager.hideSearchPanel(codeEditor)
+ textEditorManager.recentFileDialog.showRecentFileDialog = true
+ }) {
+ Icon(
+ modifier = Modifier.size(21.dp),
+ imageVector = Icons.Rounded.Folder,
+ contentDescription = null
+ )
+ }
+ }
+ SearchPanel(
+ codeEditor,
+ textEditorManager.getFileInstance()!!.searcher,
+ textEditorManager.showSearchPanel
+ )
}
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt
index 84355258..9e9d44cb 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/TextEditorManager.kt
@@ -3,21 +3,30 @@ package com.raival.compose.file.explorer.screen.textEditor
import android.content.Context
import android.content.Intent
import android.graphics.Typeface
+import android.os.Build
import android.view.ViewGroup
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.isDarkTheme
import com.raival.compose.file.explorer.common.extension.isNot
import com.raival.compose.file.explorer.common.extension.whiteSpace
-import com.raival.compose.file.explorer.screen.main.tab.files.holder.DocumentHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType
import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsonFileType
import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType
@@ -25,82 +34,47 @@ import com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANG
import com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_JSON
import com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_KOTLIN
import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder
-import com.raival.compose.file.explorer.screen.textEditor.misc.setCodeEditorLanguage
+import com.raival.compose.file.explorer.screen.textEditor.language.json.JsonCodeLanguage
+import com.raival.compose.file.explorer.screen.textEditor.language.kotlin.KotlinCodeLanguage
+import com.raival.compose.file.explorer.screen.textEditor.model.Searcher
+import com.raival.compose.file.explorer.screen.textEditor.model.WarningDialogProperties
+import com.raival.compose.file.explorer.screen.textEditor.scheme.DarkScheme
+import com.raival.compose.file.explorer.screen.textEditor.scheme.LightScheme
import io.github.rosemoe.sora.event.ContentChangeEvent
import io.github.rosemoe.sora.event.PublishSearchResultEvent
import io.github.rosemoe.sora.event.SelectionChangeEvent
+import io.github.rosemoe.sora.lang.EmptyLanguage
+import io.github.rosemoe.sora.langs.java.JavaLanguage
+import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme
+import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
import io.github.rosemoe.sora.text.Content
import io.github.rosemoe.sora.widget.CodeEditor
import io.github.rosemoe.sora.widget.component.EditorAutoCompletion
import io.github.rosemoe.sora.widget.component.Magnifier
+import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
import io.github.rosemoe.sora.widget.subscribeAlways
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
-import java.util.Locale
class TextEditorManager {
-
- object RecentFileDialog {
- var showRecentFileDialog by mutableStateOf(false)
-
- fun getRecentFiles(textEditorManager: TextEditorManager): SnapshotStateList {
- val limit = globalClass.preferencesManager.textEditorPrefs.recentFilesLimit
- var index = 0
- textEditorManager.fileInstanceList.removeIf {
- if (limit > 0 && index > limit - 1) {
- !it.file.exists() || !it.requireSave
- } else {
- !it.file.exists()
- }.also { index++ }
- }
- return textEditorManager.fileInstanceList
- }
- }
-
- object Searcher {
- var query by mutableStateOf(emptyString)
- var replace by mutableStateOf(emptyString)
- var caseSensitive by mutableStateOf(false)
- var useRegex by mutableStateOf(false)
- }
-
- data class FileInstance(
- val file: DocumentHolder,
- var content: Content,
- var lastModified: Long,
- var requireSave: Boolean = false,
- val searcher: Searcher = Searcher
- )
-
- object WarningDialogProperties {
- var showWarningDialog by mutableStateOf(false)
- var title by mutableStateOf(emptyString)
- var message by mutableStateOf(emptyString)
- var confirmText by mutableStateOf(emptyString)
- var dismissText by mutableStateOf(emptyString)
- var onConfirm: () -> Unit = {}
- var onDismiss: () -> Unit = {}
- }
-
var showSearchPanel by mutableStateOf(false)
- val warningDialogProperties = WarningDialogProperties
- val recentFileDialog = RecentFileDialog
+ val warningDialogProperties = WarningDialogProperties()
+ val recentFileDialog = RecentFileDialog()
private val untitledFileName = "untitled.txt"
- private val textEditorDir = DocumentHolder.fromFile(
- File(globalClass.appFiles.path, "textEditor").apply { if (!exists()) mkdirs() }
+ private val textEditorDir = LocalFileHolder(
+ File(globalClass.appFiles.uniquePath, "textEditor").apply { if (!exists()) mkdirs() }
)
- private val customSymbolsFile =
- DocumentHolder.fromFile(File(textEditorDir.toFile(), "symbols.txt"))
+ private val customSymbolsFile = LocalFileHolder(
+ File(textEditorDir.file, "symbols.txt")
+ )
- private val tempFile = textEditorDir.findFile(untitledFileName)
- ?: textEditorDir.createSubFile(untitledFileName)
- ?: throw RuntimeException(globalClass.getString(R.string.failed_to_create_temporary_file))
+ private val tempFile = LocalFileHolder(File(textEditorDir.file, untitledFileName))
var activeFile = tempFile
@@ -120,7 +94,7 @@ class TextEditorManager {
SymbolHolder("="),
SymbolHolder("{"),
SymbolHolder("}"),
- SymbolHolder("/"),
+ SymbolHolder(File.separator),
SymbolHolder("\\"),
SymbolHolder("<"),
SymbolHolder(">"),
@@ -133,21 +107,6 @@ class TextEditorManager {
private var customSymbolHolders = arrayListOf()
- val indentChar = " "
-
- fun parseCursorPosition(input: String): Pair {
- val trimmedInput = input.trim()
- return when {
- trimmedInput.matches(Regex("\\d+")) -> Pair(trimmedInput.toInt(), 0)
- trimmedInput.matches(Regex("\\d+:\\d+")) -> {
- val parts = trimmedInput.split(":").map { it.trim().toInt() }
- Pair(parts[0], parts[1])
- }
-
- else -> Pair(-1, -1)
- }
- }
-
fun updateSymbols() {
if (!customSymbolsFile.exists()) {
customSymbolsFile.writeText(
@@ -160,7 +119,8 @@ class TextEditorManager {
customSymbolsFile.readText(),
object : TypeToken>() {}.type
)
- } catch (_: Exception) {
+ } catch (e: Exception) {
+ logger.logError(e)
globalClass.showMsg(R.string.failed_to_load_symbols_file)
}
}
@@ -207,7 +167,7 @@ class TextEditorManager {
confirmText = globalClass.getString(R.string.reload)
dismissText = globalClass.getString(R.string.cancel)
onDismiss = { showWarningDialog = false }
- WarningDialogProperties.onConfirm = {
+ warningDialogProperties.onConfirm = {
getFileInstance()?.apply {
lastModified = activeFile.lastModified
onConfirm()
@@ -221,10 +181,10 @@ class TextEditorManager {
}
fun getFileInstance(
- documentHolder: DocumentHolder = activeFile,
+ contentHolder: LocalFileHolder = activeFile,
bringToTop: Boolean = false
): FileInstance? {
- val index = fileInstanceList.indexOfFirst { it.file.path == documentHolder.path }
+ val index = fileInstanceList.indexOfFirst { it.file.uniquePath == contentHolder.uniquePath }
if (index == -1) return null
@@ -291,17 +251,134 @@ class TextEditorManager {
}
}
+ fun getLightScheme() = LightScheme()
+
+ fun getDarkScheme() = DarkScheme()
+
+ fun resetColorScheme(codeEditor: CodeEditor, isTextmate: Boolean) {
+ codeEditor.apply {
+ if (isTextmate) {
+ ensureTextmateTheme(codeEditor)
+ if (globalClass.isDarkTheme()) {
+ ThemeRegistry.getInstance().setTheme("dark")
+ } else {
+ ThemeRegistry.getInstance().setTheme("light")
+ }
+ adaptCodeEditorScheme(colorScheme)
+ } else {
+ colorScheme = if (globalClass.isDarkTheme()) getDarkScheme() else getLightScheme()
+ adaptCodeEditorScheme(colorScheme)
+ }
+ }
+ }
+
+ fun ensureTextmateTheme(codeEditor: CodeEditor) {
+ try {
+ var editorColorScheme = codeEditor.colorScheme
+ if (editorColorScheme !is TextMateColorScheme) {
+ editorColorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance())
+ codeEditor.colorScheme = editorColorScheme
+ }
+ } catch (e: Exception) {
+ logger.logError(e)
+ }
+ }
+
+ fun adaptCodeEditorScheme(scheme: EditorColorScheme) {
+ val colorScheme = if (globalClass.isDarkTheme()) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ dynamicDarkColorScheme(globalClass)
+ } else {
+ darkColorScheme()
+ }
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ dynamicLightColorScheme(globalClass)
+ } else {
+ lightColorScheme()
+ }
+ }
+ scheme.apply {
+ setColor(
+ EditorColorScheme.LINE_NUMBER_CURRENT,
+ colorScheme.onSurface.toArgb()
+ )
+ setColor(
+ EditorColorScheme.SELECTION_HANDLE,
+ colorScheme.primary.toArgb()
+ )
+ setColor(
+ EditorColorScheme.SELECTION_INSERT,
+ colorScheme.primary.toArgb()
+ )
+ setColor(
+ EditorColorScheme.SELECTED_TEXT_BACKGROUND,
+ colorScheme.primary.copy(alpha = 0.3f).toArgb()
+ )
+ setColor(
+ EditorColorScheme.CURRENT_LINE,
+ colorScheme.surfaceContainerHigh.toArgb()
+ )
+ setColor(
+ EditorColorScheme.WHOLE_BACKGROUND,
+ colorScheme.surfaceContainerLowest.toArgb()
+ )
+ setColor(
+ EditorColorScheme.LINE_NUMBER_BACKGROUND,
+ colorScheme.surfaceContainer.toArgb()
+ )
+ setColor(
+ EditorColorScheme.LINE_NUMBER,
+ colorScheme.onSurface.copy(alpha = 0.5f).toArgb()
+ )
+ setColor(
+ EditorColorScheme.MATCHED_TEXT_BACKGROUND,
+ colorScheme.surfaceVariant.toArgb()
+ )
+ setColor(EditorColorScheme.HIGHLIGHTED_DELIMITERS_FOREGROUND, Color.Red.toArgb())
+ }
+ }
+
+ fun setCodeEditorLanguage(codeEditor: CodeEditor, language: Int) {
+ when (language) {
+ LANGUAGE_JAVA -> {
+ codeEditor.apply {
+ setEditorLanguage(JavaLanguage())
+ }
+ }
+
+ LANGUAGE_KOTLIN -> {
+ codeEditor.apply {
+ setEditorLanguage(KotlinCodeLanguage())
+ }
+ }
+
+ LANGUAGE_JSON -> {
+ codeEditor.apply {
+ setEditorLanguage(JsonCodeLanguage())
+ }
+ }
+
+ else -> {
+ codeEditor.apply {
+ setEditorLanguage(EmptyLanguage())
+ }
+ }
+ }
+ resetColorScheme(codeEditor, true)
+ }
+
private fun analyseFile() {
- activityTitle = activeFile.getName()
+ activityTitle = activeFile.displayName
activitySubtitle = activeFile.basePath
- canFormatFile = activeFile.fileExtension.lowercase(Locale.getDefault()).let {
+ canFormatFile = activeFile.extension.let {
it == jsonFileType || it == javaFileType || it == kotlinFileType
}
}
private fun setLanguage(codeEditor: CodeEditor) {
- when (activeFile.fileExtension.lowercase(Locale.getDefault())) {
+ when (activeFile.extension) {
javaFileType -> setCodeEditorLanguage(codeEditor, LANGUAGE_JAVA)
kotlinFileType -> setCodeEditorLanguage(codeEditor, LANGUAGE_KOTLIN)
jsonFileType -> setCodeEditorLanguage(codeEditor, LANGUAGE_JSON)
@@ -314,7 +391,7 @@ class TextEditorManager {
codeEditor: CodeEditor,
onContentReady: (content: Content, text: String, isSourceFileChanged: Boolean) -> Unit
) {
- if (!fileInstance.file.exists() && !fileInstance.file.isFile) {
+ if (!fileInstance.file.exists() && !fileInstance.file.isFile()) {
fileInstanceList.remove(fileInstance)
globalClass.showMsg(R.string.file_not_found)
}
@@ -378,13 +455,13 @@ class TextEditorManager {
* Opens a file in text editor.
* return true if the file exists, false otherwise
*/
- fun openTextEditor(documentHolder: DocumentHolder, context: Context): Boolean {
- if (!documentHolder.exists()) {
+ fun openTextEditor(localFileHolder: LocalFileHolder, context: Context): Boolean {
+ if (!localFileHolder.isValid()) {
globalClass.showMsg(R.string.file_not_found)
return false
}
- activeFile = getFileInstance(documentHolder)?.file ?: documentHolder
+ activeFile = getFileInstance(localFileHolder)?.file ?: localFileHolder
context.startActivity(Intent(context, TextEditorActivity::class.java))
@@ -478,4 +555,29 @@ class TextEditorManager {
}
}
}
+
+ class RecentFileDialog {
+ var showRecentFileDialog by mutableStateOf(false)
+
+ fun getRecentFiles(textEditorManager: TextEditorManager): SnapshotStateList {
+ val limit = globalClass.preferencesManager.textEditorPrefs.recentFilesLimit
+ var index = 0
+ textEditorManager.fileInstanceList.removeIf {
+ if (limit > 0 && index > limit - 1) {
+ !it.file.exists() || !it.requireSave
+ } else {
+ !it.file.exists()
+ }.also { index++ }
+ }
+ return textEditorManager.fileInstanceList
+ }
+ }
+
+ data class FileInstance(
+ val file: LocalFileHolder,
+ var content: Content,
+ var lastModified: Long,
+ var requireSave: Boolean = false,
+ val searcher: Searcher = Searcher()
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt
index 9ad22875..dd94680b 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/language/json/JsonFormatter.kt
@@ -27,7 +27,7 @@ class JsonFormatter : Formatter {
return try {
if (isObject) JSONObject(txt).toString(2)
else JSONArray(txt).toString(2)
- } catch (e: Exception) {
+ } catch (_: Exception) {
txt
}
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/misc/Utils.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/misc/Utils.kt
deleted file mode 100644
index fe63dd85..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/misc/Utils.kt
+++ /dev/null
@@ -1,151 +0,0 @@
-package com.raival.compose.file.explorer.screen.textEditor.misc
-
-import android.os.Build
-import androidx.compose.material3.darkColorScheme
-import androidx.compose.material3.dynamicDarkColorScheme
-import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.toArgb
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.common.extension.isDarkTheme
-import com.raival.compose.file.explorer.screen.textEditor.language.java.JavaCodeLanguage
-import com.raival.compose.file.explorer.screen.textEditor.language.json.JsonCodeLanguage
-import com.raival.compose.file.explorer.screen.textEditor.language.kotlin.KotlinCodeLanguage
-import com.raival.compose.file.explorer.screen.textEditor.scheme.DarkScheme
-import com.raival.compose.file.explorer.screen.textEditor.scheme.LightScheme
-import io.github.rosemoe.sora.lang.EmptyLanguage
-import io.github.rosemoe.sora.lang.Language
-import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme
-import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
-import io.github.rosemoe.sora.widget.CodeEditor
-import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
-
-val javaLanguage: Language
- get() = JavaCodeLanguage()
-
-val kotlinLanguage: Language
- get() = KotlinCodeLanguage()
-
-val jsonLanguage: Language
- get() = JsonCodeLanguage()
-
-fun getLightScheme() = LightScheme()
-
-fun getDarkScheme() = DarkScheme()
-
-fun resetColorScheme(codeEditor: CodeEditor, isTextmate: Boolean) {
- codeEditor.apply {
- if (isTextmate) {
- ensureTextmateTheme(codeEditor)
- if (globalClass.isDarkTheme()) {
- ThemeRegistry.getInstance().setTheme("dark")
- } else {
- ThemeRegistry.getInstance().setTheme("light")
- }
- val cs = colorScheme
- adaptCodeEditorScheme(cs)
-
- colorScheme = cs
- adaptCodeEditorScheme(colorScheme)
- } else {
- colorScheme = if (globalClass.isDarkTheme()) getDarkScheme() else getLightScheme()
- adaptCodeEditorScheme(colorScheme)
- }
- }
-}
-
-fun ensureTextmateTheme(codeEditor: CodeEditor) {
- try {
- var editorColorScheme = codeEditor.colorScheme
- if (editorColorScheme !is TextMateColorScheme) {
- editorColorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance())
- codeEditor.colorScheme = editorColorScheme
- }
- } catch (_: Exception) {
- }
-}
-
-fun adaptCodeEditorScheme(scheme: EditorColorScheme) {
- val colorScheme = if (globalClass.isDarkTheme()) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- dynamicDarkColorScheme(globalClass)
- } else {
- darkColorScheme()
- }
- } else {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- dynamicLightColorScheme(globalClass)
- } else {
- lightColorScheme()
- }
- }
- scheme.apply {
- setColor(
- EditorColorScheme.LINE_NUMBER_CURRENT,
- colorScheme.onSurface.toArgb()
- )
- setColor(
- EditorColorScheme.SELECTION_HANDLE,
- colorScheme.primary.toArgb()
- )
- setColor(
- EditorColorScheme.SELECTION_INSERT,
- colorScheme.primary.toArgb()
- )
- setColor(
- EditorColorScheme.SELECTED_TEXT_BACKGROUND,
- colorScheme.primary.copy(alpha = 0.3f).toArgb()
- )
- setColor(
- EditorColorScheme.CURRENT_LINE,
- colorScheme.surfaceContainerHigh.toArgb()
- )
- setColor(
- EditorColorScheme.WHOLE_BACKGROUND,
- colorScheme.surfaceContainerLowest.toArgb()
- )
- setColor(
- EditorColorScheme.LINE_NUMBER_BACKGROUND,
- colorScheme.surfaceContainer.toArgb()
- )
- setColor(
- EditorColorScheme.LINE_NUMBER,
- colorScheme.onSurface.copy(alpha = 0.5f).toArgb()
- )
- setColor(
- EditorColorScheme.MATCHED_TEXT_BACKGROUND,
- colorScheme.surfaceVariant.toArgb()
- )
- setColor(EditorColorScheme.HIGHLIGHTED_DELIMITERS_FOREGROUND, Color.Red.toArgb())
- }
-}
-
-fun setCodeEditorLanguage(codeEditor: CodeEditor, language: Int) {
- when (language) {
- com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_JAVA -> {
- codeEditor.apply {
- setEditorLanguage(javaLanguage)
- }
- }
-
- com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_KOTLIN -> {
- codeEditor.apply {
- setEditorLanguage(kotlinLanguage)
- }
- }
-
- com.raival.compose.file.explorer.screen.main.tab.files.misc.Language.LANGUAGE_JSON -> {
- codeEditor.apply {
- setEditorLanguage(jsonLanguage)
- }
- }
-
- else -> {
- codeEditor.apply {
- setEditorLanguage(EmptyLanguage())
- }
- }
- }
- resetColorScheme(codeEditor, true)
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/Searcher.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/Searcher.kt
new file mode 100644
index 00000000..c4031b51
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/Searcher.kt
@@ -0,0 +1,13 @@
+package com.raival.compose.file.explorer.screen.textEditor.model
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.raival.compose.file.explorer.common.extension.emptyString
+
+class Searcher {
+ var query by mutableStateOf(emptyString)
+ var replace by mutableStateOf(emptyString)
+ var caseSensitive by mutableStateOf(false)
+ var useRegex by mutableStateOf(false)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/WarningDialogProperties.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/WarningDialogProperties.kt
new file mode 100644
index 00000000..b944cd14
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/model/WarningDialogProperties.kt
@@ -0,0 +1,16 @@
+package com.raival.compose.file.explorer.screen.textEditor.model
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.raival.compose.file.explorer.common.extension.emptyString
+
+class WarningDialogProperties {
+ var showWarningDialog by mutableStateOf(false)
+ var title by mutableStateOf(emptyString)
+ var message by mutableStateOf(emptyString)
+ var confirmText by mutableStateOf(emptyString)
+ var dismissText by mutableStateOf(emptyString)
+ var onConfirm: () -> Unit = {}
+ var onDismiss: () -> Unit = {}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt
index 728077d5..55698141 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/BottomBarView.kt
@@ -7,15 +7,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronLeft
import androidx.compose.material.icons.rounded.ChevronRight
-import androidx.compose.material.icons.rounded.Folder
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
@@ -29,14 +25,18 @@ import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.moveSelectionBy
import com.raival.compose.file.explorer.common.ui.CustomIconButton
+import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder
import io.github.rosemoe.sora.widget.CodeEditor
import io.github.rosemoe.sora.widget.SelectionMovement
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun BottomBarView(codeEditor: CodeEditor) {
- val textEditorManager = globalClass.textEditorManager
-
+fun BottomBarView(
+ codeEditor: CodeEditor,
+ symbolList: List,
+ extraButton: @Composable () -> Unit = {},
+) {
+ val indentChar = " "
var currentCursor by remember { mutableIntStateOf(1) }
Row(
@@ -55,10 +55,7 @@ fun BottomBarView(codeEditor: CodeEditor) {
if (codeEditor.cursor.isSelected) {
codeEditor.indentSelection()
} else {
- codeEditor.insertText(
- textEditorManager.indentChar,
- textEditorManager.indentChar.length
- )
+ codeEditor.insertText(indentChar, indentChar.length)
}
},
onLongClick = {
@@ -71,7 +68,7 @@ fun BottomBarView(codeEditor: CodeEditor) {
)
}
- items(textEditorManager.getSymbols(true)) { symbol ->
+ items(symbolList) { symbol ->
SymbolBox(
label = symbol.label,
onClick = {
@@ -157,16 +154,5 @@ fun BottomBarView(codeEditor: CodeEditor) {
icon = Icons.Rounded.ChevronRight
)
}
-
- IconButton(onClick = {
- textEditorManager.hideSearchPanel(codeEditor)
- textEditorManager.recentFileDialog.showRecentFileDialog = true
- }) {
- Icon(
- modifier = Modifier.size(21.dp),
- imageVector = Icons.Rounded.Folder,
- contentDescription = null
- )
- }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt
index 15d5426e..c47cddfd 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/InfoBar.kt
@@ -14,12 +14,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.common.ui.Space
@Composable
-fun InfoBar() {
- val textEditorManager = globalClass.textEditorManager
+fun InfoBar(
+ subtitle: String
+) {
Row(
Modifier
.fillMaxWidth()
@@ -32,7 +32,7 @@ fun InfoBar() {
modifier = Modifier
.alpha(0.65f)
.animateContentSize(),
- text = textEditorManager.activitySubtitle,
+ text = subtitle,
fontSize = 10.sp
)
Spacer(modifier = Modifier.weight(1f))
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt
index 453cc61d..ed723d4f 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/JumpToPositionDialog.kt
@@ -1,39 +1,151 @@
package com.raival.compose.file.explorer.screen.textEditor.ui
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.ui.InputDialog
-import com.raival.compose.file.explorer.common.ui.InputDialogButton
-import com.raival.compose.file.explorer.common.ui.InputDialogInput
+import com.raival.compose.file.explorer.common.extension.asCodeEditorCursorCoordinates
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.ui.Space
import io.github.rosemoe.sora.widget.CodeEditor
@Composable
-fun JumpToPositionDialog(codeEditor: CodeEditor) {
- val textEditorManager = globalClass.textEditorManager
- if (textEditorManager.showJumpToPositionDialog) {
- InputDialog(
- title = stringResource(R.string.jump_to_position),
- inputs = arrayListOf(
- InputDialogInput(stringResource(R.string.jump_to_position_label))
+fun JumpToPositionDialog(
+ codeEditor: CodeEditor,
+ onDismiss: () -> Unit
+) {
+ var posInput by remember { mutableStateOf("") }
+ var error by remember { mutableStateOf("") }
+
+ LaunchedEffect(posInput) {
+ val pos = posInput.asCodeEditorCursorCoordinates()
+ error = if (posInput.isBlank()) {
+ emptyString
+ } else if (pos.first < 0 || pos.second < 0) {
+ globalClass.getString(R.string.invalid_position)
+ } else {
+ emptyString
+ }
+ }
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ ) {
+ Card(
+ shape = RoundedCornerShape(6.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
),
- buttons = arrayListOf(
- InputDialogButton(stringResource(R.string.go)) { inputs ->
- val text = inputs[0].content
- val position = textEditorManager.parseCursorPosition(text)
- if (position.first > -1) {
- runCatching {
- codeEditor.setSelection(position.first - 1, position.second - 1)
- }.exceptionOrNull()?.let {
- globalClass.showMsg(R.string.invalid_position)
- }
- } else {
- globalClass.showMsg(R.string.invalid_position)
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = stringResource(R.string.jump_to_position),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ TextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = posInput,
+ onValueChange = {
+ posInput = it
+ },
+ label = { Text(text = stringResource(R.string.jump_to_position_label)) },
+ singleLine = true,
+ shape = RoundedCornerShape(6.dp),
+ colors = TextFieldDefaults.colors(
+ errorIndicatorColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ disabledIndicatorColor = Color.Transparent
+ ),
+ isError = error.isNotEmpty(),
+ supportingText = if (error.isNotEmpty()) {
+ { Text(error) }
+ } else null
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = onDismiss,
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.cancel),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = {
+ val position = posInput.asCodeEditorCursorCoordinates()
+ if (position.first > -1) {
+ runCatching {
+ codeEditor.setSelection(position.first - 1, position.second - 1)
+ }.exceptionOrNull()?.let {
+ globalClass.showMsg(R.string.invalid_position)
+ }
+ } else {
+ globalClass.showMsg(R.string.invalid_position)
+ }
+ onDismiss()
+ },
+ enabled = error.isEmpty() && posInput.isNotBlank(),
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.go),
+ style = MaterialTheme.typography.labelLarge
+ )
}
- textEditorManager.showJumpToPositionDialog = false
}
- )
- ) { textEditorManager.showJumpToPositionDialog = false }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt
index 2be4129d..47ec8f70 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/OptionsMenu.kt
@@ -178,7 +178,8 @@ fun OptionsMenu(expanded: Boolean, codeEditor: CodeEditor, onDismissRequest: ()
preferences.deleteEmptyLineFast =
(!preferences.deleteEmptyLineFast).also { newValue ->
codeEditor.props.deleteEmptyLineFast = newValue
- } },
+ }
+ },
leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Backspace, null) },
trailingIcon = {
Checkbox(
@@ -227,9 +228,10 @@ fun OptionsMenu(expanded: Boolean, codeEditor: CodeEditor, onDismissRequest: ()
Checkbox(
checked = preferences.autoIndent,
onCheckedChange = {
- preferences.autoIndent = (!preferences.autoIndent).also { newValue ->
- codeEditor.props.autoIndent = newValue
- }
+ preferences.autoIndent =
+ (!preferences.autoIndent).also { newValue ->
+ codeEditor.props.autoIndent = newValue
+ }
}
)
}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt
index 75553763..ce019bfb 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/SearchPanel.kt
@@ -22,25 +22,27 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.ui.CheckableText
import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.textEditor.model.Searcher
import io.github.rosemoe.sora.widget.CodeEditor
import io.github.rosemoe.sora.widget.EditorSearcher
@Composable
-fun SearchPanel(codeEditor: CodeEditor) {
- val textEditorManager = globalClass.textEditorManager
-
+fun SearchPanel(
+ codeEditor: CodeEditor,
+ searcher: Searcher,
+ show: Boolean
+) {
AnimatedVisibility(
- visible = textEditorManager.showSearchPanel,
+ visible = show,
enter = expandIn(expandFrom = Alignment.TopCenter) + slideInVertically(initialOffsetY = { it }),
exit = shrinkOut(shrinkTowards = Alignment.BottomCenter) + slideOutVertically(targetOffsetY = { it })
) {
- val searcher = textEditorManager.getFileInstance()!!.searcher
fun codeEditorSearcher() = codeEditor.searcher
- fun hasQuery() = searcher.query.isNotEmpty() && codeEditorSearcher().matchedPositionCount > 0
+ fun hasQuery() =
+ searcher.query.isNotEmpty() && codeEditorSearcher().matchedPositionCount > 0
Column(
modifier = Modifier
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt
index e183cf0f..6c5a72cc 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/textEditor/ui/WarningDialog.kt
@@ -4,23 +4,21 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
-import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.screen.textEditor.model.WarningDialogProperties
@Composable
-fun WarningDialog() {
- globalClass.textEditorManager.warningDialogProperties.run {
- if (showWarningDialog) {
- AlertDialog(
- title = { Text(text = title) },
- text = { Text(text = message) },
- dismissButton = {
- TextButton(onClick = { onDismiss() }) { Text(text = dismissText) }
- },
- confirmButton = {
- TextButton(onClick = { onConfirm() }) { Text(text = confirmText) }
- },
- onDismissRequest = { onDismiss() }
- )
- }
+fun WarningDialog(properties: WarningDialogProperties) {
+ with(properties) {
+ AlertDialog(
+ title = { Text(text = title) },
+ text = { Text(text = message) },
+ dismissButton = {
+ TextButton(onClick = { onDismiss() }) { Text(text = dismissText) }
+ },
+ confirmButton = {
+ TextButton(onClick = { onConfirm() }) { Text(text = confirmText) }
+ },
+ onDismissRequest = { onDismiss() }
+ )
}
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt
index 64a7bf89..af37ad04 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/ViewerActivity.kt
@@ -5,11 +5,12 @@ import android.os.Bundle
import androidx.activity.enableEdgeToEdge
import com.raival.compose.file.explorer.App.Companion.globalClass
import com.raival.compose.file.explorer.base.BaseActivity
+import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.extension.randomString
abstract class ViewerActivity : BaseActivity() {
private var uri: Uri? = null
- private var uid: String = ""
+ private var uid: String = emptyString
private var currentInstance: ViewerInstance? = null
abstract fun onCreateNewInstance(uri: Uri, uid: String): ViewerInstance
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt
new file mode 100644
index 00000000..9e1224b2
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerActivity.kt
@@ -0,0 +1,28 @@
+package com.raival.compose.file.explorer.screen.viewer.audio
+
+import android.net.Uri
+import androidx.activity.compose.setContent
+import com.raival.compose.file.explorer.screen.viewer.ViewerActivity
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+import com.raival.compose.file.explorer.screen.viewer.audio.ui.MusicPlayerScreen
+import com.raival.compose.file.explorer.theme.FileExplorerTheme
+
+class AudioPlayerActivity : ViewerActivity() {
+ override fun onCreateNewInstance(
+ uri: Uri,
+ uid: String
+ ): ViewerInstance {
+ return AudioPlayerInstance(uri, uid)
+ }
+
+ override fun onReady(instance: ViewerInstance) {
+ setContent {
+ FileExplorerTheme {
+ MusicPlayerScreen(
+ audioPlayerInstance = instance as AudioPlayerInstance,
+ onClosed = { onBackPressedDispatcher.onBackPressed() }
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt
new file mode 100644
index 00000000..2c5e8ef9
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/AudioPlayerInstance.kt
@@ -0,0 +1,210 @@
+package com.raival.compose.file.explorer.screen.viewer.audio
+
+import android.content.Context
+import android.graphics.BitmapFactory
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import androidx.annotation.OptIn
+import androidx.media3.common.C.TIME_UNSET
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.exoplayer.ExoPlayer
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioMetadata
+import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioPlayerColorScheme
+import com.raival.compose.file.explorer.screen.viewer.audio.model.PlayerState
+import com.raival.compose.file.explorer.screen.viewer.audio.ui.extractColorsFromBitmap
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class AudioPlayerInstance(
+ override val uri: Uri,
+ override val id: String
+) : ViewerInstance {
+ private val _playerState = MutableStateFlow(PlayerState())
+ val playerState: StateFlow = _playerState.asStateFlow()
+
+ private val _metadata = MutableStateFlow(AudioMetadata())
+ val metadata: StateFlow = _metadata.asStateFlow()
+
+ private val _isEqualizerVisible = MutableStateFlow(false)
+ val isEqualizerVisible: StateFlow = _isEqualizerVisible.asStateFlow()
+
+ private val _isVolumeVisible = MutableStateFlow(false)
+ val isVolumeVisible: StateFlow = _isVolumeVisible.asStateFlow()
+
+ private val _colorScheme = MutableStateFlow(AudioPlayerColorScheme())
+ val audioPlayerColorScheme: StateFlow = _colorScheme.asStateFlow()
+
+ private var exoPlayer: ExoPlayer? = null
+ private var positionTrackingJob: Job? = null
+
+ @OptIn(UnstableApi::class)
+ suspend fun initializePlayer(context: Context, uri: Uri) {
+ withContext(Dispatchers.Main) {
+ exoPlayer = ExoPlayer.Builder(context).build().apply {
+ val mediaItem = MediaItem.Builder()
+ .setUri(uri)
+ .build()
+
+ setMediaItem(mediaItem)
+ prepare()
+
+ addListener(object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ _playerState.value = _playerState.value.copy(isPlaying = isPlaying)
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ _playerState.value = _playerState.value.copy(
+ isLoading = playbackState == Player.STATE_BUFFERING
+ )
+
+ if (playbackState == Player.STATE_READY) {
+ _playerState.value = _playerState.value.copy(
+ duration = duration
+ )
+ }
+ }
+ })
+ }
+ }
+
+ extractMetadata(context, uri)
+ startPositionTracking()
+ }
+
+ fun setDefaultColorScheme(colorScheme: AudioPlayerColorScheme) {
+ _colorScheme.value = colorScheme
+ }
+
+ private suspend fun extractMetadata(context: Context, uri: Uri) {
+ withContext(Dispatchers.IO) {
+ try {
+ val retriever = MediaMetadataRetriever()
+ retriever.setDataSource(context, uri)
+
+ val title = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE)
+ ?: globalClass.getString(R.string.unknown_title)
+ val artist = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST)
+ ?: globalClass.getString(R.string.unknown_artist)
+ val album = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM)
+ ?: globalClass.getString(R.string.unknown_album)
+ val durationStr =
+ retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
+ val duration = durationStr?.toLongOrNull() ?: 0L
+
+ // Extract album art
+ val albumArtData = retriever.embeddedPicture
+ val albumArt = albumArtData?.let { data ->
+ BitmapFactory.decodeByteArray(data, 0, data.size)
+ }
+
+ val metadata = AudioMetadata(
+ title = title,
+ artist = artist,
+ album = album,
+ duration = duration,
+ albumArt = albumArt
+ )
+
+ _metadata.value = metadata
+
+ // Extract colors from album art if available
+ albumArt?.let { bitmap ->
+ val colorScheme = extractColorsFromBitmap(bitmap, _colorScheme.value)
+ _colorScheme.value = colorScheme
+ }
+
+ retriever.release()
+ } catch (e: Exception) {
+ logger.logError(e)
+ // Fallback metadata
+ _metadata.value = AudioMetadata(
+ title = uri.lastPathSegment ?: globalClass.getString(R.string.unknown_title)
+ )
+ }
+ }
+ }
+
+ private fun startPositionTracking() {
+ positionTrackingJob?.cancel()
+ positionTrackingJob = CoroutineScope(Dispatchers.Main).launch {
+ while (true) {
+ exoPlayer?.let { player ->
+ _playerState.value = _playerState.value.copy(
+ currentPosition = player.currentPosition,
+ duration = player.duration.takeIf { it != TIME_UNSET } ?: 0L
+ )
+ }
+ delay(100)
+ }
+ }
+ }
+
+ fun playPause() {
+ exoPlayer?.let { player ->
+ if (player.isPlaying) {
+ player.pause()
+ } else {
+ player.play()
+ }
+ }
+ }
+
+ fun seekTo(position: Long) {
+ exoPlayer?.seekTo(position)
+ }
+
+ fun skipNext() {
+ exoPlayer?.seekToNext()
+ }
+
+ fun skipPrevious() {
+ exoPlayer?.seekToPrevious()
+ }
+
+ fun setPlaybackSpeed(speed: Float) {
+ exoPlayer?.setPlaybackSpeed(speed)
+ _playerState.value = _playerState.value.copy(playbackSpeed = speed)
+ }
+
+ fun toggleRepeatMode() {
+ val newMode = when (_playerState.value.repeatMode) {
+ Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
+ else -> Player.REPEAT_MODE_OFF
+ }
+ exoPlayer?.repeatMode = newMode
+ _playerState.value = _playerState.value.copy(repeatMode = newMode)
+ }
+
+ fun setVolume(volume: Float) {
+ exoPlayer?.volume = volume
+ _playerState.value = _playerState.value.copy(volume = volume)
+ }
+
+ fun toggleEqualizer() {
+ _isEqualizerVisible.value = !_isEqualizerVisible.value
+ }
+
+ fun toggleVolume() {
+ _isVolumeVisible.value = !_isVolumeVisible.value
+ }
+
+ override fun onClose() {
+ positionTrackingJob?.cancel()
+ exoPlayer?.release()
+ exoPlayer = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioMetaData.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioMetaData.kt
new file mode 100644
index 00000000..c0e81eb6
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioMetaData.kt
@@ -0,0 +1,13 @@
+package com.raival.compose.file.explorer.screen.viewer.audio.model
+
+import android.graphics.Bitmap
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+
+data class AudioMetadata(
+ val title: String = globalClass.getString(R.string.unknown_title),
+ val artist: String = globalClass.getString(R.string.unknown_artist),
+ val album: String = globalClass.getString(R.string.unknown_album),
+ val duration: Long = 0L,
+ val albumArt: Bitmap? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioPlayerColorScheme.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioPlayerColorScheme.kt
new file mode 100644
index 00000000..39fa0bd3
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/AudioPlayerColorScheme.kt
@@ -0,0 +1,19 @@
+package com.raival.compose.file.explorer.screen.viewer.audio.model
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import androidx.core.graphics.ColorUtils
+
+data class AudioPlayerColorScheme(
+ val primary: Color = Color(0xFF6750A4),
+ val secondary: Color = Color(0xFF625B71),
+ val background: Color = Color(0xFF1C1B1F),
+ val surface: Color = Color(0xFF2B2930)
+) {
+ val tintColor: Color by lazy {
+ val hsl = FloatArray(3)
+ ColorUtils.colorToHSL(primary.toArgb(), hsl)
+ hsl[2] = (hsl[2] + 0.5f).coerceIn(0f, 1f)
+ Color(ColorUtils.HSLToColor(hsl))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlayerState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlayerState.kt
new file mode 100644
index 00000000..61d797e3
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/model/PlayerState.kt
@@ -0,0 +1,13 @@
+package com.raival.compose.file.explorer.screen.viewer.audio.model
+
+import androidx.media3.common.Player
+
+data class PlayerState(
+ val isPlaying: Boolean = false,
+ val currentPosition: Long = 0L,
+ val duration: Long = 0L,
+ val isLoading: Boolean = false,
+ val playbackSpeed: Float = 1.0f,
+ val repeatMode: Int = Player.REPEAT_MODE_OFF,
+ val volume: Float = 1.0f
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt
new file mode 100644
index 00000000..0fb8bef1
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/audio/ui/AudioPlayerScreen.kt
@@ -0,0 +1,830 @@
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package com.raival.compose.file.explorer.screen.viewer.audio.ui
+
+import android.graphics.Bitmap
+import androidx.annotation.OptIn
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeDown
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Equalizer
+import androidx.compose.material.icons.filled.MusicNote
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Repeat
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
+import androidx.compose.material.icons.filled.Speed
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+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 androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.graphics.ColorUtils
+import androidx.media3.common.Player
+import androidx.palette.graphics.Palette
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.toFormattedTime
+import com.raival.compose.file.explorer.common.ui.Space
+import com.raival.compose.file.explorer.screen.viewer.audio.AudioPlayerInstance
+import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioMetadata
+import com.raival.compose.file.explorer.screen.viewer.audio.model.AudioPlayerColorScheme
+import kotlin.math.abs
+
+@Composable
+fun MusicPlayerScreen(
+ audioPlayerInstance: AudioPlayerInstance,
+ onClosed: () -> Unit
+) {
+ val context = LocalContext.current
+ val playerState by audioPlayerInstance.playerState.collectAsState()
+ val metadata by audioPlayerInstance.metadata.collectAsState()
+ val isEqualizerVisible by audioPlayerInstance.isEqualizerVisible.collectAsState()
+ val isVolumeVisible by audioPlayerInstance.isVolumeVisible.collectAsState()
+ val customColorScheme by audioPlayerInstance.audioPlayerColorScheme.collectAsState()
+ val defaultScheme = AudioPlayerColorScheme(
+ primary = MaterialTheme.colorScheme.primary,
+ secondary = MaterialTheme.colorScheme.secondary,
+ background = MaterialTheme.colorScheme.background,
+ surface = MaterialTheme.colorScheme.surface
+ )
+
+ // Initialize player
+ LaunchedEffect(audioPlayerInstance.uri) {
+ audioPlayerInstance.setDefaultColorScheme(defaultScheme)
+ audioPlayerInstance.initializePlayer(context, audioPlayerInstance.uri)
+ }
+
+ // Dispose player when leaving
+ DisposableEffect(Unit) {
+ onDispose { audioPlayerInstance.onClose() }
+ }
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = customColorScheme.background
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ customColorScheme.background,
+ customColorScheme.surface,
+ customColorScheme.background.copy(alpha = 0.8f)
+ )
+ )
+ )
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ // Status bar spacing
+ Spacer(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars))
+
+ // Top controls
+ TopControls(
+ onEqualizerClick = { audioPlayerInstance.toggleEqualizer() },
+ onVolumeClick = { audioPlayerInstance.toggleVolume() },
+ onCloseClick = onClosed,
+ audioPlayerColorScheme = customColorScheme
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Album art with rotation animation
+ AlbumArt(
+ isPlaying = playerState.isPlaying,
+ metadata = metadata,
+ audioPlayerColorScheme = customColorScheme
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Song info
+ SongInfo(metadata = metadata, colorScheme = customColorScheme)
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Progress bar
+ ProgressBar(
+ currentPosition = playerState.currentPosition,
+ duration = playerState.duration,
+ onSeek = { audioPlayerInstance.seekTo(it) },
+ colorScheme = customColorScheme
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Main controls
+ MainControls(
+ isPlaying = playerState.isPlaying,
+ isLoading = playerState.isLoading,
+ onPlayPause = { audioPlayerInstance.playPause() },
+ onSkipNext = { audioPlayerInstance.skipNext() },
+ onSkipPrevious = { audioPlayerInstance.skipPrevious() },
+ colorScheme = customColorScheme
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Additional controls
+ AdditionalControls(
+ playbackSpeed = playerState.playbackSpeed,
+ repeatMode = playerState.repeatMode,
+ onSpeedChange = { audioPlayerInstance.setPlaybackSpeed(it) },
+ onRepeatToggle = { audioPlayerInstance.toggleRepeatMode() },
+ colorScheme = customColorScheme
+ )
+ }
+
+ // Volume overlay
+ AnimatedVisibility(
+ visible = isVolumeVisible,
+ enter = slideInVertically { -it } + fadeIn() + scaleIn(initialScale = 0.6f),
+ exit = slideOutVertically { -it } + fadeOut()
+ ) {
+ VolumeView(
+ volume = playerState.volume,
+ onVolumeChange = { audioPlayerInstance.setVolume(it) },
+ onDismiss = { audioPlayerInstance.toggleVolume() },
+ colorScheme = customColorScheme
+ )
+ }
+
+ // Equalizer overlay
+ AnimatedVisibility(
+ visible = isEqualizerVisible,
+ enter = slideInVertically { -it } + fadeIn() + scaleIn(initialScale = 0.6f),
+ exit = slideOutVertically { -it } + fadeOut()
+ ) {
+ EqualizerView(
+ onDismiss = { audioPlayerInstance.toggleEqualizer() },
+ colorScheme = customColorScheme
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun TopControls(
+ onEqualizerClick: () -> Unit,
+ onVolumeClick: () -> Unit,
+ onCloseClick: () -> Unit,
+ audioPlayerColorScheme: AudioPlayerColorScheme,
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onCloseClick) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = null,
+ tint = audioPlayerColorScheme.tintColor
+ )
+ }
+
+ Spacer(Modifier.weight(1f))
+
+ Row {
+ IconButton(onClick = onVolumeClick) {
+ Icon(
+ Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = null,
+ tint = audioPlayerColorScheme.tintColor
+ )
+ }
+
+ IconButton(onClick = onEqualizerClick) {
+ Icon(
+ Icons.Default.Equalizer,
+ contentDescription = null,
+ tint = audioPlayerColorScheme.tintColor
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun AlbumArt(
+ isPlaying: Boolean,
+ metadata: AudioMetadata,
+ audioPlayerColorScheme: AudioPlayerColorScheme
+) {
+ RotatingContainer(isRotating = isPlaying) {
+ Box(
+ modifier = Modifier
+ .size(280.dp)
+ .clip(CircleShape)
+ .background(
+ Brush.radialGradient(
+ colors = listOf(
+ audioPlayerColorScheme.primary.copy(alpha = 0.6f),
+ audioPlayerColorScheme.primary.copy(alpha = 0.2f)
+ )
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ if (metadata.albumArt != null) {
+ Image(
+ bitmap = metadata.albumArt.asImageBitmap(),
+ contentDescription = null,
+ modifier = Modifier
+ .size(260.dp)
+ .clip(CircleShape),
+ contentScale = ContentScale.Crop
+ )
+ } else {
+ Icon(
+ Icons.Default.MusicNote,
+ contentDescription = null,
+ modifier = Modifier.size(120.dp),
+ tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun RotatingContainer(
+ modifier: Modifier = Modifier,
+ isRotating: Boolean,
+ rotationDuration: Int = 10000,
+ content: @Composable () -> Unit
+) {
+ var currentRotation by remember { mutableFloatStateOf(0f) }
+ val animatedRotation = remember { Animatable(0f) }
+
+ LaunchedEffect(isRotating) {
+ if (isRotating) {
+ animatedRotation.snapTo(currentRotation)
+
+ while (isRotating) {
+ animatedRotation.animateTo(
+ targetValue = currentRotation + 360f,
+ animationSpec = tween(
+ durationMillis = rotationDuration,
+ easing = LinearEasing
+ )
+ )
+ currentRotation = animatedRotation.value % 360f
+ animatedRotation.snapTo(currentRotation)
+ }
+ } else {
+ currentRotation = animatedRotation.value % 360f
+ animatedRotation.stop()
+ }
+ }
+
+ LaunchedEffect(isRotating, animatedRotation.value) {
+ if (isRotating) {
+ currentRotation = animatedRotation.value % 360f
+ }
+ }
+
+ Box(
+ modifier = modifier
+ .graphicsLayer {
+ rotationZ = if (isRotating) animatedRotation.value else currentRotation
+ }
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun SongInfo(metadata: AudioMetadata, colorScheme: AudioPlayerColorScheme) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = metadata.title,
+ color = colorScheme.tintColor,
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.headlineSmall
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = metadata.artist,
+ color = colorScheme.tintColor.copy(alpha = 0.8f),
+ fontSize = 16.sp,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ Spacer(modifier = Modifier.height(4.dp))
+
+ Text(
+ text = metadata.album,
+ color = colorScheme.tintColor.copy(alpha = 0.6f),
+ fontSize = 14.sp,
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
+
+@Composable
+fun ProgressBar(
+ currentPosition: Long,
+ duration: Long,
+ onSeek: (Long) -> Unit,
+ colorScheme: AudioPlayerColorScheme
+) {
+ var manualPosition by remember { mutableLongStateOf(0L) }
+ var manualSeek by remember { mutableFloatStateOf(0f) }
+ var isDragging by remember { mutableStateOf(false) }
+ val progress = if (duration > 0) {
+ if (abs(currentPosition - manualPosition) < 1000) {
+ (currentPosition.toFloat() / duration.toFloat()).also {
+ manualPosition = currentPosition
+ }
+ } else manualSeek
+ } else 0f
+
+ Column {
+ Slider(
+ value = if (isDragging) manualSeek else progress,
+ onValueChange = {
+ isDragging = true
+ manualSeek = it
+ },
+ onValueChangeFinished = {
+ (manualSeek * duration).toLong().let { newPosition ->
+ manualPosition = newPosition
+ onSeek(newPosition)
+ }
+ isDragging = false
+ },
+ colors = SliderDefaults.colors(
+ thumbColor = colorScheme.tintColor,
+ activeTrackColor = colorScheme.primary,
+ inactiveTrackColor = colorScheme.tintColor.copy(alpha = 0.3f)
+ ),
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = (if (isDragging) (manualSeek * duration).toLong() else manualPosition).toFormattedTime(),
+ color = colorScheme.tintColor.copy(alpha = 0.8f),
+ fontSize = 12.sp,
+ style = MaterialTheme.typography.bodySmall
+ )
+
+ Text(
+ text = duration.toFormattedTime(),
+ color = colorScheme.tintColor.copy(alpha = 0.8f),
+ fontSize = 12.sp,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+}
+
+@Composable
+fun MainControls(
+ isPlaying: Boolean,
+ isLoading: Boolean,
+ onPlayPause: () -> Unit,
+ onSkipNext: () -> Unit,
+ onSkipPrevious: () -> Unit,
+ colorScheme: AudioPlayerColorScheme
+) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(32.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Previous button
+ IconButton(
+ onClick = onSkipPrevious,
+ modifier = Modifier.size(56.dp)
+ ) {
+ Icon(
+ Icons.Default.SkipPrevious,
+ contentDescription = null,
+ tint = colorScheme.tintColor,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+
+ // Play/Pause button
+ Card(
+ modifier = Modifier
+ .size(72.dp)
+ .clickable { onPlayPause() },
+ shape = CircleShape,
+ colors = CardDefaults.cardColors(
+ containerColor = colorScheme.primary
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isLoading) {
+ CircularProgressIndicator(
+ color = colorScheme.primary,
+ modifier = Modifier.size(32.dp)
+ )
+ } else {
+ Icon(
+ imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = colorScheme.tintColor,
+ modifier = Modifier.size(40.dp)
+ )
+ }
+ }
+ }
+
+ // Next button
+ IconButton(
+ onClick = onSkipNext,
+ modifier = Modifier.size(56.dp)
+ ) {
+ Icon(
+ Icons.Default.SkipNext,
+ contentDescription = null,
+ tint = colorScheme.tintColor,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun AdditionalControls(
+ playbackSpeed: Float,
+ repeatMode: Int,
+ onSpeedChange: (Float) -> Unit,
+ onRepeatToggle: () -> Unit,
+ colorScheme: AudioPlayerColorScheme
+) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ // Speed control
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ IconButton(
+ onClick = {
+ val newSpeed = when (playbackSpeed) {
+ 0.5f -> 1.0f
+ 1.0f -> 1.25f
+ 1.25f -> 1.5f
+ 1.5f -> 2.0f
+ else -> 0.5f
+ }
+ onSpeedChange(newSpeed)
+ }
+ ) {
+ Icon(
+ Icons.Default.Speed,
+ contentDescription = null,
+ tint = colorScheme.tintColor
+ )
+ }
+ Text(
+ text = "${playbackSpeed}x",
+ color = colorScheme.tintColor.copy(alpha = 0.8f),
+ fontSize = 12.sp,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ // Repeat control (only two modes: off and all)
+ IconButton(onClick = onRepeatToggle) {
+ Icon(
+ Icons.Default.Repeat,
+ contentDescription = null,
+ tint = if (repeatMode == Player.REPEAT_MODE_OFF)
+ colorScheme.tintColor.copy(alpha = 0.5f)
+ else
+ colorScheme.tintColor,
+ )
+ }
+ }
+}
+
+@Composable
+fun VolumeView(
+ volume: Float,
+ onVolumeChange: (Float) -> Unit,
+ onDismiss: () -> Unit,
+ colorScheme: AudioPlayerColorScheme
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .windowInsetsPadding(WindowInsets.statusBars),
+ colors = CardDefaults.cardColors(
+ containerColor = colorScheme.surface.copy(alpha = 0.95f)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.volume),
+ color = colorScheme.tintColor,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ IconButton(onClick = onDismiss) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = null,
+ tint = colorScheme.tintColor
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.VolumeDown,
+ contentDescription = null,
+ tint = colorScheme.tintColor.copy(alpha = 0.7f)
+ )
+
+ Space(8.dp)
+
+ Slider(
+ value = volume,
+ onValueChange = onVolumeChange,
+ modifier = Modifier.weight(1f),
+ colors = SliderDefaults.colors(
+ thumbColor = colorScheme.tintColor,
+ activeTrackColor = colorScheme.primary,
+ inactiveTrackColor = colorScheme.tintColor.copy(alpha = 0.3f)
+ )
+ )
+
+ Space(8.dp)
+
+ Icon(
+ Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = null,
+ tint = colorScheme.tintColor.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun EqualizerView(
+ onDismiss: () -> Unit,
+ colorScheme: AudioPlayerColorScheme
+) {
+ val frequencies = listOf("60Hz", "230Hz", "910Hz", "4kHz", "14kHz")
+ val gains = remember { mutableStateListOf(0f, 0f, 0f, 0f, 0f) }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ .windowInsetsPadding(WindowInsets.statusBars),
+ colors = CardDefaults.cardColors(
+ containerColor = colorScheme.surface.copy(alpha = 0.95f)
+ ),
+ elevation = CardDefaults.cardElevation(defaultElevation = 16.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = stringResource(R.string.equalizer),
+ color = colorScheme.tintColor,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.titleLarge
+ )
+
+ IconButton(onClick = onDismiss) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = null,
+ tint = colorScheme.tintColor
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ frequencies.forEachIndexed { index, frequency ->
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "+15",
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
+ fontSize = 10.sp,
+ style = MaterialTheme.typography.labelSmall
+ )
+
+ Space(8.dp)
+
+ Slider(
+ value = gains[index],
+ onValueChange = { gains[index] = it },
+ valueRange = -15f..15f,
+ modifier = Modifier.weight(1f),
+ colors = SliderDefaults.colors(
+ thumbColor = colorScheme.tintColor,
+ activeTrackColor = colorScheme.primary,
+ inactiveTrackColor = colorScheme.tintColor.copy(alpha = 0.3f)
+ )
+ )
+
+ Space(8.dp)
+
+ Text(
+ text = "-15",
+ color = colorScheme.tintColor.copy(alpha = 0.6f),
+ fontSize = 10.sp,
+ style = MaterialTheme.typography.labelSmall
+ )
+
+ Space(8.dp)
+
+ Text(
+ text = frequency,
+ color = colorScheme.tintColor,
+ fontSize = 12.sp,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ Button(
+ onClick = {
+ for (i in gains.indices) {
+ gains[i] = 0f
+ }
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.reset),
+ color = colorScheme.tintColor
+ )
+ }
+
+ Button(
+ onClick = onDismiss,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorScheme.primary
+ )
+ ) {
+ Text(
+ text = stringResource(R.string.done),
+ color = colorScheme.tintColor
+ )
+ }
+ }
+ }
+ }
+}
+
+// Extract colors from bitmap
+fun extractColorsFromBitmap(
+ bitmap: Bitmap,
+ defaultScheme: AudioPlayerColorScheme
+): AudioPlayerColorScheme {
+ return try {
+ val palette = Palette.from(bitmap).generate()
+ val primaryColor = palette.getDominantColor(0xFF6750A4.toInt())
+ val vibrantColor = palette.getVibrantColor(primaryColor)
+ val mutedColor = palette.getMutedColor(0xFF625B71.toInt())
+
+ // Create darker variants for background
+ val primaryHsl = FloatArray(3)
+ ColorUtils.colorToHSL(primaryColor, primaryHsl)
+ primaryHsl[2] = 0.1f // Very dark
+ val backgroundColor = ColorUtils.HSLToColor(primaryHsl)
+
+ primaryHsl[2] = 0.2f // Slightly lighter
+ val surfaceColor = ColorUtils.HSLToColor(primaryHsl)
+
+ AudioPlayerColorScheme(
+ primary = Color(vibrantColor),
+ secondary = Color(mutedColor),
+ background = Color(backgroundColor),
+ surface = Color(surfaceColor)
+ )
+ } catch (_: Exception) {
+ defaultScheme // Fallback to default colors
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt
index e4bdd724..abaa0b0d 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerActivity.kt
@@ -1,24 +1,100 @@
package com.raival.compose.file.explorer.screen.viewer.image
+import android.content.Intent
+import android.graphics.Bitmap
import android.net.Uri
import androidx.activity.compose.setContent
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.RotateRight
+import androidx.compose.material.icons.filled.BorderOuter
+import androidx.compose.material.icons.filled.Crop
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.ErrorOutline
+import androidx.compose.material.icons.filled.FilterCenterFocus
+import androidx.compose.material.icons.filled.FitScreen
+import androidx.compose.material.icons.filled.Height
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.InvertColors
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.WidthFull
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.palette.graphics.Palette
+import coil3.Image
+import coil3.asDrawable
import coil3.compose.AsyncImage
+import coil3.toBitmap
+import com.anggrayudi.storage.extension.toDocumentFile
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
import com.raival.compose.file.explorer.common.extension.read
+import com.raival.compose.file.explorer.common.extension.showMsg
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.common.extension.toFormattedSize
import com.raival.compose.file.explorer.common.ui.SafeSurface
import com.raival.compose.file.explorer.screen.viewer.ViewerActivity
import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
-import com.raival.compose.file.explorer.screen.viewer.image.instance.ImageViewerInstance
import com.raival.compose.file.explorer.theme.FileExplorerTheme
+import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@@ -30,33 +106,503 @@ class ImageViewerActivity : ViewerActivity() {
override fun onReady(instance: ViewerInstance) {
setContent {
FileExplorerTheme {
- SafeSurface {
+ SafeSurface(enableStatusBarsPadding = false) {
+ ImageViewerScreen(instance)
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ImageViewerScreen(instance: ViewerInstance) {
+ val defaultColor = MaterialTheme.colorScheme.surface
+ var dominantColor by remember { mutableStateOf(defaultColor) }
+ var secondaryColor by remember { mutableStateOf(defaultColor) }
+ val imageBackgroundColors = arrayListOf(
+ Color.Transparent,
+ Color.White,
+ Color.Gray,
+ Color.Black
+ )
+ var currentImageBackgroundColorIndex by remember { mutableIntStateOf(0) }
+ var imageData by remember { mutableStateOf(ByteArray(0)) }
+ var isLoading by remember { mutableStateOf(true) }
+ var isError by remember { mutableStateOf(false) }
+ var showControls by remember { mutableStateOf(true) }
+ var showInfo by remember { mutableStateOf(false) }
+ var imageInfo by remember { mutableStateOf(null) }
+ var rotationAngle by remember { mutableFloatStateOf(0f) }
+ var imageDimensions by remember { mutableStateOf("" to "") }
+ var contentScale by remember { mutableStateOf(ContentScale.Fit) }
+ val context = LocalContext.current
+ val zoomState = rememberZoomState()
+ val scope = rememberCoroutineScope()
+
+ // Load image data
+ LaunchedEffect(instance.uri) {
+ try {
+ imageData = instance.uri.read()
+ isLoading = false
+ } catch (e: Exception) {
+ logger.logError(e)
+ isError = true
+ isLoading = false
+ }
+ }
+
+ // Extract image info when image is loaded
+ LaunchedEffect(imageData, imageDimensions.first) {
+ if (imageData.isNotEmpty() && imageDimensions.first.isNotEmpty()) {
+ imageInfo =
+ extractImageInfo(instance.uri, imageDimensions.first, imageDimensions.second)
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ dominantColor.copy(alpha = 0.8f),
+ secondaryColor.copy(alpha = 0.8f)
+ )
+ )
+ )
+ ) {
+ when {
+ isLoading -> LoadingState()
+ isError -> ErrorState(onRetry = {
+ scope.launch {
+ try {
+ imageData = instance.uri.read()
+ isLoading = false
+ } catch (e: Exception) {
+ logger.logError(e)
+ isError = true
+ isLoading = false
+ }
+ }
+ })
+
+ else -> {
+ // Main image with zoom and rotation
+ var image by remember { mutableStateOf(null) }
+
+ AsyncImage(
+ model = imageData,
+ contentDescription = null,
+ contentScale = contentScale,
+ modifier = Modifier
+ .fillMaxSize()
+ .graphicsLayer { rotationZ = rotationAngle }
+ .zoomable(
+ zoomState = zoomState,
+ onTap = { showControls = !showControls },
+ )
+ .background(imageBackgroundColors[currentImageBackgroundColorIndex]),
+ onSuccess = { state ->
+ image = state.result.image
+ val drawable = state.result.image.asDrawable(context.resources)
+ imageDimensions =
+ "${drawable.intrinsicWidth}" to "${drawable.intrinsicHeight}"
+ }
+ )
+
+ // Extract dominant color
+ LaunchedEffect(image) {
+ if (image != null) {
+ val bitmap = image!!.toBitmap().copy(Bitmap.Config.ARGB_8888, false)
+ val palette = Palette.from(bitmap).generate()
+ dominantColor = Color(palette.getDominantColor(defaultColor.toArgb()))
+ secondaryColor = Color(palette.getMutedColor(defaultColor.toArgb()))
+ }
+ }
+
+ // Animated gradient overlay for controls
+ AnimatedVisibility(
+ visible = showControls,
+ enter = fadeIn(spring(stiffness = Spring.StiffnessMedium)),
+ exit = fadeOut(spring(stiffness = Spring.StiffnessMedium))
+ ) {
Box(
- Modifier.fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- val image = remember { mutableStateOf(ByteArray(0)) }
- var isLoaded by remember { mutableStateOf(false) }
-
- LaunchedEffect(Unit) {
- image.value = instance.uri.read()
- isLoaded = true
- }
-
- if (!isLoaded) {
- CircularProgressIndicator()
- }
-
- AsyncImage(
- modifier = Modifier
- .fillMaxSize()
- .zoomable(rememberZoomState()),
- model = image.value,
- contentDescription = null
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ defaultColor.copy(alpha = 0.9f),
+ Color.Transparent,
+ Color.Transparent,
+ Color.Transparent,
+ defaultColor.copy(alpha = 0.9f)
+ )
+ )
+ )
+ )
+ }
+
+ // Top bar
+ AnimatedVisibility(
+ visible = showControls,
+ enter = slideInVertically { -it } + fadeIn(),
+ exit = slideOutVertically { -it } + fadeOut()
+ ) {
+ TopAppBar(
+ title = {
+ Column {
+ Text(
+ text = imageInfo?.name ?: stringResource(R.string.unknown),
+ color = Color.White,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ imageInfo?.let { info ->
+ Text(
+ text = "${info.size} • ${info.dimensions}",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 12.sp
+ )
+ }
+ }
+ },
+ navigationIcon = {
+ IconButton(onClick = {
+ (context as? ViewerActivity)?.finish()
+ }) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ tint = Color.White
+ )
+ }
+ },
+ actions = {
+ IconButton(onClick = { showInfo = true }) {
+ Icon(
+ Icons.Default.Info,
+ contentDescription = null,
+ tint = Color.White
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color.Transparent
+ )
+ )
+ }
+
+ // Bottom controls
+ AnimatedVisibility(
+ modifier = Modifier.align(Alignment.BottomCenter),
+ visible = showControls,
+ enter = slideInVertically { it } + fadeIn(),
+ exit = slideOutVertically { it } + fadeOut()
+ ) {
+ BottomControls(
+ onInvertBackgroundColors = {
+ currentImageBackgroundColorIndex =
+ (currentImageBackgroundColorIndex + 1) % imageBackgroundColors.size
+ },
+ onRotate = {
+ rotationAngle = (rotationAngle + 90f) % 360f
+ },
+ onEdit = {
+ val editIntent = Intent(Intent.ACTION_EDIT)
+ val mimeType =
+ context.contentResolver.getType(instance.uri) ?: "image/*"
+ editIntent.setDataAndType(instance.uri, mimeType)
+ editIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+ val chooser = Intent.createChooser(
+ editIntent,
+ globalClass.getString(R.string.edit_with)
+ )
+ if (editIntent.resolveActivity(context.packageManager) != null) {
+ context.startActivity(chooser)
+ } else {
+ showMsg(context.getString(R.string.no_app_found_to_edit_this_image))
+ }
+ },
+ onContentScale = {
+ contentScale = it
+ },
+ modifier = Modifier.align(Alignment.BottomCenter),
+ )
+ }
+
+ // Info bottom sheet
+ if (showInfo) {
+ imageInfo?.let { info ->
+ ImageInfoBottomSheet(
+ imageInfo = info,
+ onDismiss = { showInfo = false }
)
}
}
}
}
}
+}
+
+@Composable
+private fun LoadingState() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ color = MaterialTheme.colorScheme.primary,
+ strokeWidth = 4.dp
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.loading_image),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+ }
+ }
+}
+
+@Composable
+private fun ErrorState(onRetry: () -> Unit) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ Icons.Default.ErrorOutline,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = MaterialTheme.colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.failed_to_load_image),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.check_file_exists_and_try_again),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = onRetry,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(Icons.Default.Refresh, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.retry))
+ }
+ }
+ }
+}
+
+@Composable
+private fun BottomControls(
+ onInvertBackgroundColors: () -> Unit,
+ onRotate: () -> Unit,
+ onEdit: () -> Unit,
+ onContentScale: (contentScale: ContentScale) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val contentScales = arrayListOf().apply {
+ add(ContentScale.Fit)
+ add(ContentScale.Crop)
+ add(ContentScale.FillWidth)
+ add(ContentScale.FillHeight)
+ add(ContentScale.FillBounds)
+ add(ContentScale.Inside)
+ }
+ var selectedContentScale by remember { mutableStateOf(contentScales[0]) }
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Invert background colors
+ ActionButton(
+ icon = Icons.Default.InvertColors,
+ onClick = onInvertBackgroundColors,
+ backgroundColor = Color.White.copy(alpha = 0.15f)
+ )
+
+ // Fit to screen
+ ActionButton(
+ icon = when (selectedContentScale) {
+ ContentScale.Fit -> Icons.Default.FitScreen
+ ContentScale.Crop -> Icons.Default.Crop
+ ContentScale.FillWidth -> Icons.Default.WidthFull
+ ContentScale.FillHeight -> Icons.Default.Height
+ ContentScale.FillBounds -> Icons.Default.BorderOuter
+ else -> Icons.Default.FilterCenterFocus
+ },
+ onClick = {
+ val currentScaleIndex = contentScales.indexOf(selectedContentScale)
+ selectedContentScale = contentScales[
+ if (currentScaleIndex == contentScales.lastIndex) 0
+ else currentScaleIndex + 1
+ ]
+ onContentScale(selectedContentScale)
+ },
+ backgroundColor = Color.White.copy(alpha = 0.15f)
+ )
+
+ // Rotate
+ ActionButton(
+ icon = Icons.AutoMirrored.Filled.RotateRight,
+ onClick = onRotate,
+ backgroundColor = Color.White.copy(alpha = 0.15f)
+ )
+
+ // Edit
+ ActionButton(
+ icon = Icons.Default.Edit,
+ onClick = onEdit,
+ backgroundColor = Color.White.copy(alpha = 0.15f)
+ )
+ }
+}
+
+@Composable
+private fun ActionButton(
+ icon: ImageVector,
+ onClick: () -> Unit,
+ tint: Color = Color.White,
+ backgroundColor: Color = Color.White.copy(alpha = 0.1f)
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(backgroundColor)
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ tint = tint,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ImageInfoBottomSheet(
+ imageInfo: ImageInfo,
+ onDismiss: () -> Unit
+) {
+ val bottomSheetState = rememberModalBottomSheetState(
+ skipPartiallyExpanded = true
+ )
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = bottomSheetState,
+ dragHandle = {
+ Surface(
+ modifier = Modifier.padding(vertical = 8.dp),
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Box(modifier = Modifier.size(width = 32.dp, height = 4.dp))
+ }
+ }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp)
+ ) {
+ Text(
+ text = "Image Details",
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(bottom = 16.dp)
+ )
+
+ InfoRow(stringResource(R.string.name), imageInfo.name)
+ InfoRow(stringResource(R.string.size), imageInfo.size)
+ InfoRow(stringResource(R.string.dimensions), imageInfo.dimensions)
+ InfoRow(stringResource(R.string.format), imageInfo.format)
+ InfoRow(stringResource(R.string.last_modified), imageInfo.lastModified)
+ InfoRow(stringResource(R.string.path), imageInfo.path)
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
+ modifier = Modifier.weight(1f)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.weight(2f)
+ )
+ }
+}
+
+// Data class for image information
+data class ImageInfo(
+ val name: String,
+ val size: String,
+ val dimensions: String,
+ val format: String,
+ val lastModified: String,
+ val path: String
+)
+
+// Helper function to extract image information
+private fun extractImageInfo(uri: Uri, width: String, height: String): ImageInfo {
+ val file = uri.toDocumentFile(globalClass)
+
+ return ImageInfo(
+ name = file?.name ?: emptyString,
+ size = (file?.length() ?: 0).toFormattedSize(),
+ dimensions = if (width.isNotEmpty() && height.isNotEmpty()) "$width × $height" else globalClass.getString(
+ R.string.unknown
+ ),
+ format = globalClass.contentResolver.getType(uri)
+ ?.substringAfter("image/", globalClass.getString(R.string.not_available))
+ ?.uppercase()
+ ?: globalClass.getString(R.string.not_available),
+ lastModified = (file?.lastModified() ?: 0).toFormattedDate(),
+ path = uri.path.toString()
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerInstance.kt
new file mode 100644
index 00000000..a412095e
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/ImageViewerInstance.kt
@@ -0,0 +1,13 @@
+package com.raival.compose.file.explorer.screen.viewer.image
+
+import android.net.Uri
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+
+class ImageViewerInstance(
+ override val uri: Uri,
+ override val id: String
+) : ViewerInstance {
+ override fun onClose() {
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/instance/ImageViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/instance/ImageViewerInstance.kt
deleted file mode 100644
index 8b2c521d..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/image/instance/ImageViewerInstance.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.image.instance
-
-import android.net.Uri
-import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
-
-class ImageViewerInstance(override val uri: Uri, override val id: String) : ViewerInstance {
- override fun onClose() {
-
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/MediaViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/MediaViewerActivity.kt
deleted file mode 100644
index 1ffe5625..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/MediaViewerActivity.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.media
-
-import android.net.Uri
-import androidx.activity.compose.setContent
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.viewinterop.AndroidView
-import androidx.media3.common.Player
-import androidx.media3.ui.PlayerView
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.ui.KeepScreenOn
-import com.raival.compose.file.explorer.common.ui.SafeSurface
-import com.raival.compose.file.explorer.screen.viewer.ViewerActivity
-import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
-import com.raival.compose.file.explorer.screen.viewer.media.instance.MediaViewerInstance
-import com.raival.compose.file.explorer.screen.viewer.media.misc.MediaSource
-import com.raival.compose.file.explorer.screen.viewer.media.ui.AudioPlayer
-import com.raival.compose.file.explorer.theme.FileExplorerTheme
-
-class MediaViewerActivity : ViewerActivity() {
- override fun onCreateNewInstance(uri: Uri, uid: String): ViewerInstance {
- return MediaViewerInstance(uri, uid)
- }
-
- override fun onReady(instance: ViewerInstance) {
- if (instance is MediaViewerInstance) {
- setContent {
- KeepScreenOn()
- FileExplorerTheme {
- SafeSurface {
- if (instance.mediaSource is MediaSource.AudioSource) {
- AudioPlayer(instance)
- } else {
- Box(
- Modifier
- .fillMaxSize(),
- contentAlignment = Alignment.Center
- ) {
- LaunchedEffect(Unit) {
- instance.player.play()
- }
-
- AndroidView(
- modifier = Modifier
- .fillMaxSize(),
- factory = { context ->
- PlayerView(context).apply {
- useController =
- instance.mediaSource is MediaSource.VideoSource
- player = instance.player.apply {
- repeatMode = Player.REPEAT_MODE_ONE
- }
- }
- },
- update = { },
- onRelease = {
- it.player?.release()
- }
- )
- }
- }
- }
- }
- }
- } else {
- globalClass.showMsg(getString(R.string.invalid_media_file))
- finish()
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/instance/MediaViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/instance/MediaViewerInstance.kt
deleted file mode 100644
index 30bbd52a..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/instance/MediaViewerInstance.kt
+++ /dev/null
@@ -1,51 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.media.instance
-
-import android.content.Context
-import android.net.Uri
-import androidx.media3.common.MediaItem
-import androidx.media3.exoplayer.ExoPlayer
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
-import com.raival.compose.file.explorer.screen.viewer.media.misc.AudioPlayerManager
-import com.raival.compose.file.explorer.screen.viewer.media.misc.MediaSource
-
-class MediaViewerInstance(override val uri: Uri, override val id: String) : ViewerInstance {
- val player = ExoPlayer.Builder(globalClass).build()
- val mediaItem = MediaItem.fromUri(uri)
- val mediaSource = getMediaSource(globalClass, uri)
-
- val audioManager = AudioPlayerManager(globalClass)
-
- init {
- player.setMediaItem(mediaItem)
- player.prepare()
-
- if (mediaSource is MediaSource.AudioSource) {
- audioManager.prepare(uri)
- }
- }
-
- private fun getMediaSource(context: Context, uri: Uri): MediaSource {
- val contentResolver = context.contentResolver
- val mimeType = contentResolver.getType(uri) ?: return MediaSource.UnknownSource
-
- return when {
- mimeType.startsWith("audio/") -> {
- MediaSource.AudioSource
- }
-
- mimeType.startsWith("video/") -> {
- MediaSource.VideoSource
- }
-
- else -> {
- MediaSource.UnknownSource
- }
- }
- }
-
- override fun onClose() {
- player.release()
- audioManager.release()
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/AudioPlayerManager.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/AudioPlayerManager.kt
deleted file mode 100644
index 759af7fd..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/AudioPlayerManager.kt
+++ /dev/null
@@ -1,155 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.media.misc
-
-import android.content.Context
-import android.net.Uri
-import android.os.Handler
-import android.os.Looper
-import androidx.media3.common.MediaItem
-import androidx.media3.common.MediaMetadata
-import androidx.media3.common.Player
-import androidx.media3.exoplayer.ExoPlayer
-
-/**
- * A simple audio manager using Media3 ExoPlayer.
- */
-class AudioPlayerManager(context: Context) {
-
- private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context).build()
- private var listener: AudioPlayerManagerListener? = null
-
- // Handler to update playback progress every second.
- private val updateHandler = Handler(Looper.getMainLooper())
- private val updateRunnable = object : Runnable {
- override fun run() {
- val currentPosition = exoPlayer.currentPosition
- val duration = exoPlayer.duration.takeIf { it > 0 } ?: 0L
- val remaining = if (duration > currentPosition) duration - currentPosition else 0L
- listener?.onProgressUpdated(currentPosition, remaining)
- updateHandler.postDelayed(this, 1000)
- }
- }
-
- /**
- * Set a listener to receive callbacks.
- */
- fun setListener(listener: AudioPlayerManagerListener) {
- this.listener = listener
- }
-
- /**
- * Prepares the player with the given audio URI.
- */
- fun prepare(uri: Uri) {
- val mediaItem = MediaItem.fromUri(uri)
- exoPlayer.setMediaItem(mediaItem)
- exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
- exoPlayer.prepare()
-
- // Listen for playback and metadata events.
- exoPlayer.addListener(object : Player.Listener {
- override fun onPlaybackStateChanged(state: Int) {
- // For simplicity, we consider the player "playing" when playWhenReady is true and state is ready.
- listener?.onPlaybackStateChanged(exoPlayer.playWhenReady && state == Player.STATE_READY)
- }
-
- override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
- val title = mediaMetadata.title?.toString()
- val album = mediaMetadata.albumTitle?.toString()
- val artist = mediaMetadata.artist?.toString()
- val artwork = mediaMetadata.artworkData ?: mediaMetadata.artworkUri
- val duration = exoPlayer.duration.takeIf { it > 0 } ?: 0L
-
- val metadata = AudioMetadata(title, album, artist, artwork, duration)
- listener?.onMetadataChanged(metadata)
- }
- })
- }
-
- /**
- * Starts playback and begins periodic progress updates.
- */
- fun play() {
- exoPlayer.playWhenReady = true
- updateHandler.post(updateRunnable)
- }
-
- /**
- * Pauses playback and stops progress updates.
- */
- fun pause() {
- exoPlayer.playWhenReady = false
- updateHandler.removeCallbacks(updateRunnable)
- }
-
- /**
- * Stops playback completely.
- */
- fun stop() {
- exoPlayer.stop()
- updateHandler.removeCallbacks(updateRunnable)
- }
-
- /**
- * Fast-forwards playback by 5 seconds.
- */
- fun forward() {
- val newPosition = exoPlayer.currentPosition + 2000L
- exoPlayer.seekTo(newPosition)
- }
-
- /**
- * Rewinds playback by 5 seconds.
- */
- fun backward() {
- val newPosition = (exoPlayer.currentPosition - 2000L).coerceAtLeast(0L)
- exoPlayer.seekTo(newPosition)
- }
-
- /**
- * Seek to a new position
- */
- fun seekTo(position: Long) {
- val newPosition = position.coerceIn(0, exoPlayer.duration)
- exoPlayer.seekTo(position)
- }
-
- /**
- * Releases player resources.
- */
- fun release() {
- updateHandler.removeCallbacks(updateRunnable)
- exoPlayer.release()
- }
-
- /**
- * Callback interface for playback state, progress, and metadata updates.
- */
- interface AudioPlayerManagerListener {
- /**
- * Called when playback state changes.
- * @param isPlaying true if the audio is playing.
- */
- fun onPlaybackStateChanged(isPlaying: Boolean)
-
- /**
- * Called periodically with the current position and remaining time (in milliseconds).
- */
- fun onProgressUpdated(currentPosition: Long, remainingTime: Long)
-
- /**
- * Called when metadata for the current media item is available.
- */
- fun onMetadataChanged(metadata: AudioMetadata)
- }
-
- /**
- * Data class to hold basic audio metadata.
- */
- data class AudioMetadata(
- val title: String?,
- val album: String?,
- val artist: String?,
- val artwork: Any?,
- val duration: Long
- )
-}
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/MediaSource.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/MediaSource.kt
deleted file mode 100644
index 6e632472..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/misc/MediaSource.kt
+++ /dev/null
@@ -1,7 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.media.misc
-
-sealed class MediaSource {
- data object AudioSource : MediaSource()
- data object VideoSource : MediaSource()
- data object UnknownSource : MediaSource()
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/AudioPlayer.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/AudioPlayer.kt
deleted file mode 100644
index f9176cf4..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/AudioPlayer.kt
+++ /dev/null
@@ -1,235 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.media.ui
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.rounded.Pause
-import androidx.compose.material.icons.rounded.PlayArrow
-import androidx.compose.material3.FloatingActionButton
-import androidx.compose.material3.FloatingActionButtonDefaults
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Slider
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableLongStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import coil3.compose.AsyncImage
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.name
-import com.raival.compose.file.explorer.common.ui.Space
-import com.raival.compose.file.explorer.screen.viewer.media.instance.MediaViewerInstance
-import com.raival.compose.file.explorer.screen.viewer.media.misc.AudioPlayerManager
-import kotlin.time.DurationUnit
-import kotlin.time.toDuration
-
-@Composable
-fun AudioPlayer(instance: MediaViewerInstance) {
- var playing by remember { mutableStateOf(false) }
- var title by remember { mutableStateOf("") }
- var artist by remember { mutableStateOf("") }
- var album by remember { mutableStateOf("") }
- var currentTime by remember { mutableLongStateOf(0L) }
- var duration by remember { mutableLongStateOf(0L) }
- var isDraggingSlider by remember { mutableStateOf(false) }
- var manualSeek by remember { mutableFloatStateOf(0f) }
- var artwork by remember { mutableStateOf(null) }
-
- val playerManager = instance.audioManager.apply {
- setListener(object : AudioPlayerManager.AudioPlayerManagerListener {
- override fun onPlaybackStateChanged(isPlaying: Boolean) {
- playing = isPlaying
- }
-
- override fun onProgressUpdated(currentPosition: Long, remainingTime: Long) {
- currentTime = currentPosition
- }
-
- override fun onMetadataChanged(metadata: AudioPlayerManager.AudioMetadata) {
- title = metadata.title ?: ""
- artist = metadata.artist ?: ""
- album = metadata.album ?: ""
- duration = metadata.duration
- artwork = metadata.artwork
- }
- })
- }
-
- Column(
- Modifier
- .fillMaxSize()
- .background(MaterialTheme.colorScheme.surfaceContainer)
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Box(
- Modifier
- .weight(1f)
- .padding(52.dp),
- ) {
- if (artwork != null) {
- AsyncImage(
- model = artwork,
- contentDescription = null,
- modifier = Modifier
- .align(Alignment.Center)
- .fillMaxSize()
- )
- } else {
- RotatingDisk(
- modifier = Modifier
- .align(Alignment.Center)
- .fillMaxSize(),
- enabled = playing
- )
- }
- }
-
- Text(
- text = title.ifEmpty {
- instance.uri.name ?: globalClass.getString(R.string.unknown)
- },
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.titleLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Space(12.dp)
-
- Text(
- text = artist.ifEmpty { album.ifEmpty { globalClass.getString(R.string.unknown_artist) } },
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.titleMedium
- )
-
- Space(16.dp)
-
- Slider(
- modifier = Modifier.fillMaxWidth(),
- value = if (isDraggingSlider) manualSeek else currentTime.toFloat(),
- onValueChange = {
- isDraggingSlider = true
- manualSeek = it
- },
- onValueChangeFinished = {
- playerManager.seekTo(manualSeek.toLong())
- currentTime = manualSeek.toLong()
- isDraggingSlider = false
- },
- valueRange = 0f..duration.toFloat()
- )
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp)
- ) {
- Text(
- text = buildString {
- val value =
- if (isDraggingSlider) manualSeek else currentTime.toFloat()
- value.toLong().toDuration(DurationUnit.MILLISECONDS)
- .toComponents { hours, minutes, seconds, nanoseconds ->
- if (hours > 0) {
- append("%02d:".format(hours))
- }
- append("%02d:".format(minutes))
- append("%02d".format(seconds))
- }
- },
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.bodySmall
- )
- Spacer(Modifier.weight(1f))
- Text(
- text = buildString {
- duration.toDuration(DurationUnit.MILLISECONDS)
- .toComponents { hours, minutes, seconds, nanoseconds ->
- if (hours > 0) {
- append("%02d:".format(hours))
- }
- append("%02d:".format(minutes))
- append("%02d".format(seconds))
- }
- },
- color = MaterialTheme.colorScheme.onSurface,
- style = MaterialTheme.typography.bodySmall
- )
- }
-
- Space(8.dp)
-
- Row(
- Modifier
- .fillMaxWidth()
- .padding(12.dp),
- horizontalArrangement = Arrangement.Center,
- verticalAlignment = Alignment.CenterVertically
- ) {
- FloatingActionButton(
- onClick = {
- playerManager.backward()
- currentTime - 2
- }
- ) {
- Text("-2s")
- }
-
- Space(16.dp)
-
- FloatingActionButton(
- modifier = Modifier.size(FloatingActionButtonDefaults.LargeIconSize + 42.dp),
- shape = FloatingActionButtonDefaults.largeShape,
- onClick = {
- playing = !playing
-
- if (playing) {
- playerManager.play()
- } else {
- playerManager.pause()
- }
-
- duration = instance.player.duration
- }
- ) {
- Icon(
- modifier = Modifier.size(
- FloatingActionButtonDefaults.LargeIconSize
- ),
- imageVector = if (!playing) Icons.Rounded.PlayArrow
- else Icons.Rounded.Pause,
- contentDescription = null
- )
- }
-
- Space(16.dp)
-
- FloatingActionButton(
- onClick = {
- playerManager.forward()
- currentTime + 2
- }
- ) {
- Text("+2s")
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/RotatingDisk.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/RotatingDisk.kt
deleted file mode 100644
index 157f1b22..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/media/ui/RotatingDisk.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.media.ui
-
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.Easing
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.LinearEasing
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.Canvas
-import androidx.compose.material3.MaterialTheme.colorScheme
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.drawscope.DrawScope
-import androidx.compose.ui.graphics.drawscope.rotate
-import androidx.compose.ui.graphics.graphicsLayer
-import kotlinx.coroutines.launch
-import kotlin.math.cos
-import kotlin.math.sin
-import kotlin.random.Random
-
-@Composable
-fun RotatingDisk(
- modifier: Modifier = Modifier,
- enabled: Boolean = true,
- rotationEasing: Easing = LinearEasing,
- diskColor: Color = colorScheme.surfaceContainerHighest,
- dotColor: Color = colorScheme.surfaceContainer
-) {
- val rotation = remember { Animatable(0f) }
- val sizeAnimation = remember { Animatable(1f) }
- val dotRadiusFraction = remember { 0.056f }
- val dotMargin = remember { 15f }
-
- LaunchedEffect(enabled) {
- launch {
- while (enabled) {
- rotation.animateTo(
- targetValue = rotation.value + 1,
- animationSpec = tween(durationMillis = 50, easing = rotationEasing)
- )
- if (rotation.value == 360f) {
- rotation.snapTo(0f)
- }
- }
- }
- }
-
- LaunchedEffect(enabled) {
- launch {
- while (enabled) {
- sizeAnimation.animateTo(
- targetValue = Random.nextInt(95, 105) / 100f,
- animationSpec = tween(
- durationMillis = Random.nextInt(50, 250),
- easing = FastOutSlowInEasing
- )
- )
- }
- }
- }
-
- Canvas(
- modifier = modifier.graphicsLayer {
- scaleX = sizeAnimation.value
- scaleY = sizeAnimation.value
- },
- onDraw = {
- val radius = size.minDimension / 2
- val dotRadius = radius * dotRadiusFraction
- val dotAngle = 45f
-
- drawRotatingDisk(diskColor, radius, rotation.value)
- drawRotatingDot(dotColor, radius, dotRadius, dotAngle, dotMargin, rotation.value)
- }
- )
-}
-
-fun DrawScope.drawRotatingDisk(color: Color, radius: Float, rotationValue: Float) {
- rotate(rotationValue, pivot = center) {
- drawCircle(
- color = color,
- radius = radius,
- center = center
- )
- }
-}
-
-fun DrawScope.drawRotatingDot(
- color: Color,
- radius: Float,
- dotRadius: Float,
- angle: Float,
- margin: Float,
- rotationValue: Float
-) {
- val dotX = center.x + (radius - dotRadius - margin) * cos(Math.toRadians(angle.toDouble())).toFloat()
- val dotY = center.y - (radius - dotRadius - margin) * sin(Math.toRadians(angle.toDouble())).toFloat()
-
- rotate(rotationValue, pivot = center) {
- drawCircle(
- color = color,
- radius = dotRadius,
- center = Offset(dotX, dotY)
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt
index 7a54172f..9176171a 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerActivity.kt
@@ -4,204 +4,517 @@ import android.net.Uri
import android.util.Size
import androidx.activity.compose.setContent
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.rounded.Description
+import androidx.compose.material.icons.rounded.Error
+import androidx.compose.material.icons.rounded.Info
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
+import coil3.compose.AsyncImage
import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
import com.raival.compose.file.explorer.common.extension.dp
import com.raival.compose.file.explorer.common.ui.SafeSurface
import com.raival.compose.file.explorer.screen.viewer.ViewerActivity
import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
-import com.raival.compose.file.explorer.screen.viewer.pdf.instance.PdfViewerInstance
-import com.raival.compose.file.explorer.screen.viewer.pdf.ui.BottomBarView
-import com.raival.compose.file.explorer.screen.viewer.pdf.ui.TopBarView
+import com.raival.compose.file.explorer.screen.viewer.pdf.misc.PdfPageHolder
+import com.raival.compose.file.explorer.screen.viewer.pdf.ui.InfoDialog
import com.raival.compose.file.explorer.theme.FileExplorerTheme
-import my.nanihadesuka.compose.LazyColumnScrollbar
+import my.nanihadesuka.compose.InternalLazyColumnScrollbar
import my.nanihadesuka.compose.ScrollbarLayoutSide
import my.nanihadesuka.compose.ScrollbarSettings
+import net.engawapg.lib.zoomable.ExperimentalZoomableApi
import net.engawapg.lib.zoomable.rememberZoomState
-import net.engawapg.lib.zoomable.zoomable
+import net.engawapg.lib.zoomable.zoomableWithScroll
class PdfViewerActivity : ViewerActivity() {
override fun onCreateNewInstance(uri: Uri, uid: String): ViewerInstance {
return PdfViewerInstance(uri, uid)
}
+ @OptIn(ExperimentalZoomableApi::class)
override fun onReady(instance: ViewerInstance) {
if (instance is PdfViewerInstance) {
setContent {
FileExplorerTheme {
- SafeSurface {
- Box(
- Modifier.fillMaxSize(),
- contentAlignment = Alignment.TopCenter
- ) {
- var showToolbar by remember { mutableStateOf(false) }
-
- BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
- var pages by remember { mutableStateOf(emptyList()) }
- var pageSize = remember { Size(-1, -1) }
-
- LaunchedEffect(Unit) {
- if (!instance.isLoaded) {
- instance.load(
- dimension = Size(
- constraints.maxWidth,
- constraints.maxHeight
- )
- )
- }
-
- pages = instance.pages
- }
-
- DisposableEffect(Unit) {
- onDispose {
- instance.onClose()
- }
- }
-
- val listState = rememberLazyListState()
-
- LazyColumnScrollbar(
- state = listState,
- settings = ScrollbarSettings.Default.copy(
- thumbUnselectedColor = colorScheme.surfaceContainerHigh,
- thumbSelectedColor = colorScheme.surfaceContainerHighest,
- side = ScrollbarLayoutSide.Start
- ),
- indicatorContent = { index, isPressed ->
- Box(
- modifier = Modifier
- .background(
- color = if (isPressed) colorScheme.surfaceContainerHighest else colorScheme.surfaceContainerHigh,
- shape = RoundedCornerShape(
- 0.dp,
- 12.dp,
- 12.dp,
- 0.dp
- )
- )
- .padding(8.dp)
- ) {
- Text(
- text = "${index + 1}",
- fontSize = 12.sp,
- textAlign = TextAlign.Center,
- modifier = Modifier.align(Alignment.Center)
- )
- }
- }
- ) {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .zoomable(
- zoomState = rememberZoomState(),
- onTap = {
- showToolbar = !showToolbar
- }
- ),
- state = listState,
- verticalArrangement = Arrangement.spacedBy(2.dp)
- ) {
- items(pages, key = { it.index }) { page ->
- DisposableEffect(Unit) {
- page.load()
-
- pageSize = Size(
- page.dimension.width,
- page.dimension.height
- )
-
- onDispose {
- page.recycle(false)
- }
- }
-
- when (val pageContent = page.pageContent) {
- is PdfViewerInstance.PageContent.BlankPage -> {
- val width =
- if (page.isTemporaryPageSize && pageSize.width > 0)
- pageSize.width.dp() else pageContent.width.dp()
- val height =
- if (page.isTemporaryPageSize && pageSize.height > 0)
- pageSize.height.dp() else pageContent.height.dp()
-
- Box(
- modifier = Modifier
- .size(width, height)
- .background(color = Color.White)
- )
- }
-
- is PdfViewerInstance.PageContent.BitmapPage -> {
- Image(
- modifier = Modifier.size(
- pageContent.bitmap.width.dp(),
- pageContent.bitmap.height.dp()
- ),
- bitmap = pageContent.bitmap.asImageBitmap(),
- contentDescription = null,
- )
- }
- }
- }
- }
- }
- }
-
- AnimatedVisibility(
- visible = showToolbar,
- enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
- exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
- ) {
- TopBarView(instance)
- }
-
- AnimatedVisibility(
- modifier = Modifier.align(Alignment.BottomCenter),
- visible = showToolbar,
- enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
- exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
- ) {
- BottomBarView(instance)
- }
- }
+ SafeSurface(false) {
+ PdfViewerContent(instance = instance)
}
}
}
} else {
- globalClass.showMsg("Invalid PDF")
+ globalClass.showMsg(getString(R.string.invalid_pdf))
finish()
}
}
+
+ @OptIn(ExperimentalZoomableApi::class)
+ @Composable
+ private fun PdfViewerContent(instance: PdfViewerInstance) {
+ BoxWithConstraints(
+ Modifier.fillMaxSize(),
+ contentAlignment = Alignment.TopCenter
+ ) {
+ val constraints = this.constraints
+ var showToolbars by remember { mutableStateOf(true) }
+ var isLoading by remember { mutableStateOf(true) }
+ var errorMessage by remember { mutableStateOf(null) }
+ val pdfPages = remember { mutableStateListOf() }
+ var showInfoDialog by remember { mutableStateOf(false) }
+
+ val listState = rememberLazyListState()
+ val zoomState = rememberZoomState()
+ var defaultPageSize by remember { mutableStateOf(Size(0, 0)) }
+ val isFirstItemVisible by remember {
+ derivedStateOf {
+ listState.firstVisibleItemIndex == 0
+ }
+ }
+
+ val visiblePageNumbers by remember {
+ derivedStateOf {
+ val visiblePages = listState.layoutInfo.visibleItemsInfo.map { visibleItem ->
+ visibleItem.index
+ }.filter { it > 0 }
+ buildString {
+ append(visiblePages.firstOrNull() ?: 1)
+ if (visiblePages.size > 1) {
+ append("-")
+ append(visiblePages.lastOrNull() ?: 1)
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(isFirstItemVisible) {
+ showToolbars = isFirstItemVisible
+ }
+
+ LaunchedEffect(Unit) {
+ instance.prepare { success ->
+ if (success) {
+ defaultPageSize = Size(
+ constraints.maxWidth,
+ instance.defaultPageSize.height * constraints.maxWidth / instance.defaultPageSize.width
+ )
+ pdfPages.addAll(instance.pages)
+ isLoading = false
+ } else {
+ errorMessage = getString(R.string.failed_to_load_pdf)
+ isLoading = false
+ }
+ }
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ instance.onClose()
+ }
+ }
+
+ if (showInfoDialog) {
+ InfoDialog(
+ title = globalClass.getString(R.string.pdf_info),
+ properties = instance.getInfo(),
+ onDismiss = { showInfoDialog = false }
+ )
+ }
+
+ when {
+ isLoading -> LoadingScreen()
+ errorMessage != null -> ErrorScreen(
+ message = errorMessage!!,
+ onRetry = { finish() }
+ )
+
+ else -> {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .zoomableWithScroll(
+ zoomState = zoomState,
+ onTap = { if (!isFirstItemVisible) showToolbars = !showToolbars }
+ ),
+ state = listState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ item { Spacer(modifier = Modifier.height(100.dp)) }
+
+ items(
+ items = pdfPages,
+ key = { it.index }
+ ) { page ->
+ PdfPageItem(
+ page = page,
+ pageSize = defaultPageSize,
+ instance = instance
+ )
+ }
+ }
+ InternalLazyColumnScrollbar(
+ modifier = Modifier.padding(top = 110.dp),
+ state = listState,
+ settings = ScrollbarSettings.Default.copy(
+ thumbUnselectedColor = colorScheme.surfaceContainerHigh,
+ thumbSelectedColor = colorScheme.primary,
+ side = ScrollbarLayoutSide.Start,
+ thumbThickness = 6.dp,
+ scrollbarPadding = 12.dp
+ ),
+ indicatorContent = { index, isPressed ->
+ PageIndicator(
+ pageNumber = visiblePageNumbers,
+ isPressed = isPressed,
+ totalPages = pdfPages.size
+ )
+ }
+ )
+
+ TopToolbar(
+ visible = showToolbars,
+ title = instance.metadata.name,
+ onBackClick = { onBackPressedDispatcher.onBackPressed() },
+ onInfoClick = {
+ showInfoDialog = true
+ }
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun PdfPageItem(
+ page: PdfPageHolder,
+ pageSize: Size,
+ instance: PdfViewerInstance
+ ) {
+ var bitmap by remember(page.index) { mutableStateOf(page.bitmap) }
+ var isLoading by remember(page.index) { mutableStateOf(false) }
+
+ DisposableEffect(page.index) {
+ if (bitmap == null && !isLoading) {
+ isLoading = true
+ instance.fetchPage(page) { readyPage ->
+ bitmap = readyPage.bitmap
+ isLoading = false
+ }
+ }
+ onDispose {
+ instance.recycle(page)
+ }
+ }
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .wrapContentHeight(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
+ shape = RoundedCornerShape(4.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = colorScheme.surfaceContainerLowest
+ )
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(pageSize.height.dp())
+ .background(Color.White),
+ contentAlignment = Alignment.Center
+ ) {
+ when {
+ bitmap != null -> {
+ AsyncImage(
+ modifier = Modifier.fillMaxSize(),
+ model = bitmap!!,
+ contentDescription = stringResource(R.string.page, page.index + 1),
+ contentScale = ContentScale.Fit
+ )
+ }
+
+ isLoading -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(32.dp),
+ color = colorScheme.primary,
+ strokeWidth = 3.dp
+ )
+ }
+
+ else -> {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Description,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = stringResource(R.string.page, page.index + 1),
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
+ )
+ }
+ }
+ }
+
+ // Page number overlay
+ Surface(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp),
+ color = colorScheme.primary.copy(alpha = 0.9f),
+ shape = RoundedCornerShape(6.dp),
+ shadowElevation = 2.dp
+ ) {
+ Text(
+ text = "${page.index + 1}",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.labelMedium,
+ color = colorScheme.onPrimary
+ )
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun LoadingScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ color = colorScheme.primary,
+ strokeWidth = 4.dp
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.loading_pdf),
+ style = MaterialTheme.typography.bodyLarge,
+ color = colorScheme.onSurface
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun ErrorScreen(
+ message: String,
+ onRetry: () -> Unit
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(32.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Error,
+ contentDescription = null,
+ modifier = Modifier.size(64.dp),
+ tint = colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.error),
+ style = MaterialTheme.typography.headlineSmall,
+ color = colorScheme.error
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = colorScheme.onSurface,
+ textAlign = TextAlign.Center
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Button(
+ onClick = onRetry,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = colorScheme.primary
+ )
+ ) {
+ Text(stringResource(R.string.go_back))
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun PageIndicator(
+ pageNumber: String,
+ isPressed: Boolean,
+ totalPages: Int,
+ ) {
+ Card(
+ modifier = Modifier
+ .padding(start = 4.dp)
+ .alpha(0.8f),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isPressed)
+ colorScheme.primaryContainer
+ else
+ colorScheme.surfaceContainer
+ ),
+ elevation = CardDefaults.cardElevation(
+ defaultElevation = if (isPressed) 8.dp else 4.dp
+ )
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = pageNumber,
+ style = MaterialTheme.typography.titleMedium,
+ color = if (isPressed)
+ colorScheme.onPrimaryContainer
+ else
+ colorScheme.onSurface
+ )
+ Text(
+ text = "of $totalPages",
+ style = MaterialTheme.typography.labelSmall,
+ color = if (isPressed)
+ colorScheme.onPrimaryContainer
+ else
+ colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+
+ @Composable
+ private fun TopToolbar(
+ visible: Boolean,
+ title: String,
+ onBackClick: () -> Unit,
+ onInfoClick: () -> Unit
+ ) {
+ AnimatedVisibility(
+ visible = visible,
+ enter = slideInVertically(
+ initialOffsetY = { -it },
+ animationSpec = tween(500)
+ ) + fadeIn(animationSpec = tween(500)),
+ exit = slideOutVertically(
+ targetOffsetY = { -it },
+ animationSpec = tween(500)
+ ) + fadeOut(animationSpec = tween(500))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = colorScheme.surfaceContainer)
+ ) {
+ Spacer(
+ Modifier
+ .fillMaxWidth()
+ .wrapContentHeight()
+ .windowInsetsPadding(WindowInsets.statusBars)
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onBackClick) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
+ contentDescription = null,
+ tint = colorScheme.onSurface
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(horizontal = 8.dp)
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = colorScheme.onSurface
+ )
+ }
+
+ IconButton(onClick = onInfoClick) {
+ Icon(
+ imageVector = Icons.Rounded.Info,
+ contentDescription = null,
+ tint = colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerInstance.kt
new file mode 100644
index 00000000..a7fbb6cf
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/PdfViewerInstance.kt
@@ -0,0 +1,124 @@
+package com.raival.compose.file.explorer.screen.viewer.pdf
+
+import android.graphics.pdf.PdfRenderer
+import android.net.Uri
+import android.text.format.Formatter
+import android.util.Size
+import com.anggrayudi.storage.extension.toDocumentFile
+import com.raival.compose.file.explorer.App
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.name
+import com.raival.compose.file.explorer.common.extension.toFormattedDate
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+import com.raival.compose.file.explorer.screen.viewer.pdf.misc.PdfMetadata
+import com.raival.compose.file.explorer.screen.viewer.pdf.misc.PdfPageHolder
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+
+class PdfViewerInstance(
+ override val uri: Uri,
+ override val id: String
+) : ViewerInstance {
+ private val fileDescriptor = try {
+ App.Companion.globalClass.contentResolver.openFileDescriptor(uri, "r")
+ } catch (e: Exception) {
+ logger.logError(e)
+ null
+ }
+
+ private val pdfRenderer = fileDescriptor?.let {
+ try {
+ PdfRenderer(it)
+ } catch (e: Exception) {
+ logger.logError(e)
+ null
+ }
+ }
+
+ val pages = arrayListOf()
+ var defaultPageSize = Size(0, 0)
+ private set
+
+ private val scope = CoroutineScope(Dispatchers.IO)
+ private val mutex = Mutex()
+
+ val metadata by lazy {
+ PdfMetadata(
+ name = uri.name ?: App.Companion.globalClass.getString(R.string.unknown),
+ path = uri.toString(),
+ size = fileDescriptor?.statSize ?: 0L,
+ lastModified = uri.toDocumentFile(App.Companion.globalClass)?.lastModified() ?: 0L,
+ pages = pdfRenderer?.pageCount ?: 0
+ )
+ }
+
+ private var isReady = false
+
+ fun prepare(onPrepared: (success: Boolean) -> Unit) {
+ try {
+ if (!isValid()) {
+ onPrepared(false)
+ return
+ }
+
+ if (!isReady) {
+ pages.clear()
+
+ for (i in 0 until pdfRenderer!!.pageCount) {
+ pages.add(PdfPageHolder(index = i))
+ }
+
+ val samplePageIndex = if (pages.size == 1) 0 else minOf(1, pages.size - 1)
+ pdfRenderer.openPage(samplePageIndex).use { page ->
+ defaultPageSize = Size(page.width, page.height)
+ }
+
+ isReady = true
+ }
+ onPrepared(true)
+ } catch (e: Exception) {
+ logger.logError(e)
+ onPrepared(false)
+ }
+ }
+
+ fun getInfo(): List> = listOf(
+ App.Companion.globalClass.getString(R.string.name) to metadata.name,
+ App.Companion.globalClass.getString(R.string.page_count) to metadata.pages.toString(),
+ App.Companion.globalClass.getString(R.string.size) to Formatter.formatFileSize(
+ App.Companion.globalClass,
+ metadata.size
+ ),
+ App.Companion.globalClass.getString(R.string.path) to metadata.path,
+ App.Companion.globalClass.getString(R.string.last_modified) to metadata.lastModified.toFormattedDate()
+ )
+
+ fun fetchPage(page: PdfPageHolder, scale: Float = 2f, onFinished: (PdfPageHolder) -> Unit) {
+ if (pdfRenderer != null) {
+ page.fetch(scale, scope, mutex, pdfRenderer, onFinished = { onFinished(page) })
+ }
+ }
+
+ fun recycle(page: PdfPageHolder) {
+ page.recycle()
+ }
+
+ fun isValid(): Boolean = pdfRenderer != null
+
+ override fun onClose() {
+ runBlocking {
+ try {
+ scope.cancel()
+ pages.forEach { it.recycle() }
+ pdfRenderer?.close()
+ fileDescriptor?.close()
+ } catch (e: Exception) {
+ logger.logError(e)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/instance/PdfViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/instance/PdfViewerInstance.kt
deleted file mode 100644
index 0b35d812..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/instance/PdfViewerInstance.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.pdf.instance
-
-import android.graphics.Bitmap
-import android.graphics.pdf.PdfRenderer
-import android.net.Uri
-import android.util.Size
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import com.raival.compose.file.explorer.App.Companion.globalClass
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.common.extension.name
-import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-
-class PdfViewerInstance(override val uri: Uri, override val id: String) : ViewerInstance {
- sealed interface PageContent {
- data class BlankPage(val width: Int, val height: Int) : PageContent
- data class BitmapPage(val bitmap: Bitmap) : PageContent
- }
-
- class PdfPage(
- val index: Int,
- val pdfRenderer: PdfRenderer,
- val mutex: Mutex,
- val scope: CoroutineScope,
- val initDimension: Size
- ) {
- var isLoaded = false
- var job: Job? = null
- var dimension = Size(initDimension.width, initDimension.height)
- var isTemporaryPageSize = true
-
- var pageContent by mutableStateOf(
- PageContent.BlankPage(
- width = dimension.width,
- height = dimension.height
- )
- )
-
- private fun createBitmap() = Bitmap.createBitmap(
- dimension.width,
- dimension.height,
- Bitmap.Config.ARGB_8888
- ).apply {
- eraseColor(android.graphics.Color.WHITE)
- }
-
- fun load(forceLoad: Boolean = false) {
- if (forceLoad || !isLoaded) {
- job = scope.launch {
- mutex.withLock {
- pdfRenderer.let { renderer ->
- renderer.openPage(index).use { page ->
- if (isTemporaryPageSize) dimension = Size(
- dimension.width,
- (page.height * (initDimension.width.toFloat() / page.width)).toInt()
- )
-
- isTemporaryPageSize = false
-
- val bitmap = createBitmap()
-
- page.render(
- bitmap,
- null,
- null,
- PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
- )
-
- isLoaded = true
-
- pageContent = PageContent.BitmapPage(bitmap)
- }
- }
- }
- }
- }
- }
-
- fun recycle(terminal: Boolean) {
- val image = (pageContent as? PageContent.BitmapPage)?.bitmap
-
- if (!terminal && pageContent is PageContent.BitmapPage) {
- pageContent = PageContent.BlankPage(
- width = dimension.width,
- height = dimension.height
- )
- }
-
- image?.recycle()
-
- isLoaded = false
- }
- }
-
- private val fileDescriptor = globalClass.contentResolver.openFileDescriptor(uri, "r")
- private val pdfRenderer = fileDescriptor?.let { PdfRenderer(it) }
- val isValidPdf = pdfRenderer != null
- var isLoaded = false
- var pdfFileName = uri.name ?: globalClass.getString(R.string.unknown)
- private val scope = CoroutineScope(Dispatchers.Default)
- private val mutex = Mutex()
- val pageCount = pdfRenderer?.pageCount ?: 0
- val pages = arrayListOf()
-
- fun load(dimension: Size, reload: Boolean = false) {
- if (isValidPdf && (!isLoaded || reload)) {
- if (pages.isNotEmpty()) {
- pages.forEach {
- it.recycle(true)
- }
- pages.clear()
- }
-
- repeat(pageCount) { index ->
- pages.add(
- PdfPage(
- index = index,
- pdfRenderer = pdfRenderer!!,
- mutex = mutex,
- scope = scope,
- initDimension = dimension
- )
- )
- }
-
- isLoaded = true
- }
- }
-
- override fun onClose() {
- runBlocking {
- pages.forEach {
- runCatching {
- it.recycle(true)
- pdfRenderer?.close()
- fileDescriptor?.close()
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfMetadata.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfMetadata.kt
new file mode 100644
index 00000000..ca498a46
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfMetadata.kt
@@ -0,0 +1,9 @@
+package com.raival.compose.file.explorer.screen.viewer.pdf.misc
+
+data class PdfMetadata(
+ val path: String,
+ val name: String,
+ val size: Long,
+ val lastModified: Long,
+ val pages: Int,
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfPageHolder.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfPageHolder.kt
new file mode 100644
index 00000000..210a811a
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/misc/PdfPageHolder.kt
@@ -0,0 +1,75 @@
+package com.raival.compose.file.explorer.screen.viewer.pdf.misc
+
+import android.graphics.Bitmap
+import android.graphics.pdf.PdfRenderer
+import android.graphics.pdf.PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY
+import android.util.Size
+import androidx.core.graphics.createBitmap
+import com.raival.compose.file.explorer.App.Companion.logger
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+data class PdfPageHolder(
+ val index: Int,
+ var size: Size = Size(0, 0),
+ var bitmap: Bitmap? = null,
+ var isLoading: Boolean = false
+) {
+ private var job: Job? = null
+
+ fun fetch(
+ scale: Float,
+ scope: CoroutineScope,
+ mutex: Mutex,
+ pdfRenderer: PdfRenderer,
+ debounceMs: Long = 150L,
+ onFinished: () -> Unit
+ ) {
+ if (isLoading || bitmap != null) return
+
+ job?.cancel()
+ job = scope.launch {
+ // Small debounce to avoid loading during fast scroll
+ delay(debounceMs)
+
+ isLoading = true
+ try {
+ mutex.withLock {
+ if (job?.isCancelled == false && bitmap == null) {
+ pdfRenderer.openPage(index).use { page ->
+ size = Size(page.width, page.height)
+ val newBitmap = createBitmap(
+ (size.width * scale).toInt(),
+ (size.height * scale).toInt()
+ )
+ newBitmap.eraseColor(android.graphics.Color.WHITE)
+ page.render(newBitmap, null, null, RENDER_MODE_FOR_DISPLAY)
+
+ if (job?.isCancelled == false) {
+ bitmap = newBitmap
+ onFinished()
+ } else {
+ recycle()
+ }
+ }
+ }
+ }
+ } catch (e: Exception) {
+ logger.logError(e)
+ } finally {
+ isLoading = false
+ }
+ }
+ }
+
+ fun recycle() {
+ job?.cancel()
+ bitmap?.recycle()
+ bitmap = null
+ isLoading = false
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/BottomBarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/BottomBarView.kt
deleted file mode 100644
index 319913d0..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/BottomBarView.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.pdf.ui
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme.colorScheme
-import androidx.compose.material3.MaterialTheme.typography
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import com.raival.compose.file.explorer.R
-import com.raival.compose.file.explorer.screen.viewer.pdf.instance.PdfViewerInstance
-
-@Composable
-fun BottomBarView(instance: PdfViewerInstance) {
- Row(
- modifier = Modifier
- .padding(12.dp)
- .background(
- color = colorScheme.surfaceContainer.copy(alpha = 0.6f),
- shape = RoundedCornerShape(16.dp)
- )
- .padding(12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.page_count, instance.pageCount),
- maxLines = 2,
- style = typography.titleMedium,
- overflow = TextOverflow.Ellipsis
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/PdfInfoDialog.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/PdfInfoDialog.kt
new file mode 100644
index 00000000..a92f57f9
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/PdfInfoDialog.kt
@@ -0,0 +1,150 @@
+package com.raival.compose.file.explorer.screen.viewer.pdf.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.wrapContentHeight
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+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 androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.ui.Space
+
+@Composable
+fun InfoDialog(
+ title: String,
+ properties: List>,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(
+ dismissOnBackPress = true,
+ dismissOnClickOutside = true,
+ usePlatformDefaultWidth = false
+ )
+ ) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth(0.9f)
+ .wrapContentHeight(),
+ shape = RoundedCornerShape(6.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 6.dp
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp)
+ ) {
+ // Title
+ Column {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Space(8.dp)
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ Space(12.dp)
+
+ // Properties list
+ if (properties.isNotEmpty()) {
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(properties) { (property, value) ->
+ PropertyItem(
+ property = property,
+ value = value
+ )
+ }
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.no_information_available),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(vertical = 16.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ // Action button
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Button(
+ modifier = Modifier.weight(1f),
+ onClick = onDismiss,
+ shape = RoundedCornerShape(6.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.close),
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PropertyItem(
+ property: String,
+ value: String,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(12.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerLow
+ ) {
+ Column(
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Text(
+ text = property,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/TopBarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/TopBarView.kt
deleted file mode 100644
index 55f250a7..00000000
--- a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/pdf/ui/TopBarView.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.raival.compose.file.explorer.screen.viewer.pdf.ui
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme.colorScheme
-import androidx.compose.material3.MaterialTheme.typography
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import com.raival.compose.file.explorer.screen.viewer.pdf.instance.PdfViewerInstance
-
-@Composable
-fun TopBarView(instance: PdfViewerInstance) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(12.dp)
- .background(
- color = colorScheme.surfaceContainer.copy(alpha = 0.6f),
- shape = RoundedCornerShape(16.dp)
- )
- .padding(12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = instance.pdfFileName,
- maxLines = 2,
- style = typography.titleMedium,
- overflow = TextOverflow.Ellipsis
- )
- }
-}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerActivity.kt
new file mode 100644
index 00000000..45ab974b
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerActivity.kt
@@ -0,0 +1,158 @@
+package com.raival.compose.file.explorer.screen.viewer.text
+
+import android.net.Uri
+import androidx.activity.compose.BackHandler
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.compose.LifecycleEventEffect
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.ui.SafeSurface
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsonFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType
+import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder
+import com.raival.compose.file.explorer.screen.textEditor.language.json.JsonCodeLanguage
+import com.raival.compose.file.explorer.screen.textEditor.language.kotlin.KotlinCodeLanguage
+import com.raival.compose.file.explorer.screen.textEditor.ui.BottomBarView
+import com.raival.compose.file.explorer.screen.textEditor.ui.InfoBar
+import com.raival.compose.file.explorer.screen.textEditor.ui.JumpToPositionDialog
+import com.raival.compose.file.explorer.screen.textEditor.ui.SearchPanel
+import com.raival.compose.file.explorer.screen.textEditor.ui.WarningDialog
+import com.raival.compose.file.explorer.screen.viewer.ViewerActivity
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+import com.raival.compose.file.explorer.screen.viewer.text.ui.ToolbarView
+import com.raival.compose.file.explorer.theme.FileExplorerTheme
+import io.github.rosemoe.sora.event.ContentChangeEvent
+import io.github.rosemoe.sora.event.PublishSearchResultEvent
+import io.github.rosemoe.sora.event.SelectionChangeEvent
+import io.github.rosemoe.sora.lang.EmptyLanguage
+import io.github.rosemoe.sora.langs.java.JavaLanguage
+import io.github.rosemoe.sora.widget.CodeEditor
+import io.github.rosemoe.sora.widget.subscribeAlways
+
+class TextViewerActivity : ViewerActivity() {
+ private lateinit var codeEditor: CodeEditor
+
+ override fun onCreateNewInstance(
+ uri: Uri,
+ uid: String
+ ): ViewerInstance {
+ return TextViewerInstance(uri, uid)
+ }
+
+ override fun onReady(instance: ViewerInstance) {
+ val textViewerInstance = instance as TextViewerInstance
+ codeEditor = textViewerInstance.createCodeEditorView(this)
+ setContent {
+ FileExplorerTheme {
+ SafeSurface {
+ Column(
+ Modifier.fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val symbols = remember { mutableStateListOf() }
+ val activityScope = rememberCoroutineScope()
+
+ LaunchedEffect(Unit) {
+ textViewerInstance.readContent(scope = activityScope) {
+ codeEditor.apply {
+ setText(textViewerInstance.content, false, null)
+ subscribeAlways {
+ textViewerInstance.selectionChangeListener(
+ this
+ )
+ }
+ subscribeAlways {
+ textViewerInstance.selectionChangeListener(
+ this
+ )
+ }
+ subscribeAlways {
+ textViewerInstance.content = it.editor.text.toString()
+ textViewerInstance.requireSave = true
+ textViewerInstance.canUndo = canUndo()
+ textViewerInstance.canRedo = canRedo()
+ }
+ }
+ val name = textViewerInstance.uriContent.name
+ if (name != null) {
+ if (name.endsWith(javaFileType)) {
+ codeEditor.setEditorLanguage(JavaLanguage())
+ textViewerInstance.changeFontStyle(codeEditor)
+ } else if (name.endsWith(kotlinFileType)) {
+ codeEditor.setEditorLanguage(KotlinCodeLanguage())
+ textViewerInstance.changeFontStyle(codeEditor)
+ } else if (name.endsWith(jsonFileType)) {
+ codeEditor.setEditorLanguage(JsonCodeLanguage())
+ textViewerInstance.changeFontStyle(codeEditor)
+ } else {
+ codeEditor.setEditorLanguage(EmptyLanguage())
+ }
+ }
+ }
+
+ symbols.addAll(textViewerInstance.updateSymbols())
+ }
+
+ BackHandler(enabled = textViewerInstance.showSearchPanel) {
+ textViewerInstance.hideSearchPanel(codeEditor)
+ }
+
+ LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
+ textViewerInstance.checkActiveFileValidity(
+ onSourceReload = {
+ codeEditor.setText(it, true, null)
+ },
+ onFileNotFound = {
+ globalClass.showMsg(getString(R.string.file_no_longer_exists))
+ finish()
+ }
+ )
+ }
+
+ if (textViewerInstance.warningDialogProperties.showWarningDialog) {
+ WarningDialog(textViewerInstance.warningDialogProperties)
+ }
+
+ if (textViewerInstance.showJumpToPositionDialog) {
+ JumpToPositionDialog(codeEditor) {
+ textViewerInstance.showJumpToPositionDialog = false
+ }
+ }
+
+ ToolbarView(textViewerInstance, codeEditor, onBackPressedDispatcher)
+ HorizontalDivider()
+ InfoBar(textViewerInstance.activitySubtitle)
+ AndroidView(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ factory = { codeEditor },
+ update = {},
+ onRelease = { codeEditor.release() }
+ )
+ HorizontalDivider()
+ BottomBarView(codeEditor, symbols)
+ SearchPanel(
+ codeEditor,
+ textViewerInstance.searcher,
+ textViewerInstance.showSearchPanel
+ )
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerInstance.kt
new file mode 100644
index 00000000..78f6f3ed
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/TextViewerInstance.kt
@@ -0,0 +1,355 @@
+package com.raival.compose.file.explorer.screen.viewer.text
+
+import android.content.Context
+import android.graphics.Typeface
+import android.net.Uri
+import android.os.Build
+import android.view.ViewGroup
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.toArgb
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import com.google.gson.reflect.TypeToken
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.App.Companion.logger
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.emptyString
+import com.raival.compose.file.explorer.common.extension.exists
+import com.raival.compose.file.explorer.common.extension.getUriInfo
+import com.raival.compose.file.explorer.common.extension.isDarkTheme
+import com.raival.compose.file.explorer.common.extension.lastModified
+import com.raival.compose.file.explorer.common.extension.whiteSpace
+import com.raival.compose.file.explorer.screen.main.tab.files.holder.LocalFileHolder
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.javaFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.jsonFileType
+import com.raival.compose.file.explorer.screen.main.tab.files.misc.FileMimeType.kotlinFileType
+import com.raival.compose.file.explorer.screen.textEditor.holder.SymbolHolder
+import com.raival.compose.file.explorer.screen.textEditor.model.Searcher
+import com.raival.compose.file.explorer.screen.textEditor.model.WarningDialogProperties
+import com.raival.compose.file.explorer.screen.textEditor.scheme.DarkScheme
+import com.raival.compose.file.explorer.screen.textEditor.scheme.LightScheme
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+import io.github.rosemoe.sora.langs.textmate.TextMateColorScheme
+import io.github.rosemoe.sora.langs.textmate.registry.ThemeRegistry
+import io.github.rosemoe.sora.widget.CodeEditor
+import io.github.rosemoe.sora.widget.component.EditorAutoCompletion
+import io.github.rosemoe.sora.widget.component.Magnifier
+import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.BufferedReader
+import java.io.BufferedWriter
+import java.io.File
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+
+class TextViewerInstance(
+ override val uri: Uri,
+ override val id: String
+) : ViewerInstance {
+ val uriContent = uri.getUriInfo(globalClass)
+
+ val warningDialogProperties = WarningDialogProperties()
+ val searcher = Searcher()
+
+ var activityTitle by mutableStateOf(uriContent.name ?: globalClass.getString(R.string.unknown))
+ var activitySubtitle by mutableStateOf(emptyString)
+ var canUndo by mutableStateOf(false)
+ var canRedo by mutableStateOf(false)
+ var canFormatFile by mutableStateOf(false)
+ var requireSave by mutableStateOf(false)
+ var isSaving by mutableStateOf(false)
+ var showSearchPanel by mutableStateOf(false)
+ var showJumpToPositionDialog by mutableStateOf(false)
+ var content by mutableStateOf(emptyString)
+ var lastModified by mutableLongStateOf(uriContent.lastModified ?: 0L)
+
+ private val textEditorDir = LocalFileHolder(
+ File(globalClass.appFiles.uniquePath, "textEditor").apply { if (!exists()) mkdirs() }
+ )
+
+ private val customSymbolsFile = LocalFileHolder(
+ File(textEditorDir.file, "symbols.txt")
+ )
+
+ private val defaultSymbolHolders = listOf(
+ "_", "=", "{", "}", File.separator, "\\", "<", ">", "|", "?", "+", "-", "*"
+ ).map { SymbolHolder(it) }
+ private var customSymbolHolders = arrayListOf()
+
+ fun readContent(scope: CoroutineScope, onLoaded: () -> Unit) {
+ analyseFile()
+ scope.launch {
+ content = readSourceFile(this)
+ onLoaded()
+ }
+ }
+
+ private suspend fun readSourceFile(scope: CoroutineScope): String {
+ return withContext(scope.coroutineContext) {
+ globalClass.contentResolver.openInputStream(uri)?.use { inputStream ->
+ BufferedReader(InputStreamReader(inputStream)).use { reader ->
+ reader.readText()
+ }
+ } ?: emptyString
+ }
+ }
+
+ fun getLightScheme() = LightScheme()
+
+ fun getDarkScheme() = DarkScheme()
+
+ fun resetColorScheme(codeEditor: CodeEditor, isTextmate: Boolean) {
+ codeEditor.apply {
+ if (isTextmate) {
+ ensureTextmateTheme(codeEditor)
+ if (globalClass.isDarkTheme()) {
+ ThemeRegistry.getInstance().setTheme("dark")
+ } else {
+ ThemeRegistry.getInstance().setTheme("light")
+ }
+ adaptCodeEditorScheme(colorScheme)
+ } else {
+ colorScheme = if (globalClass.isDarkTheme()) getDarkScheme() else getLightScheme()
+ adaptCodeEditorScheme(colorScheme)
+ }
+ }
+ }
+
+ fun ensureTextmateTheme(codeEditor: CodeEditor) {
+ try {
+ var editorColorScheme = codeEditor.colorScheme
+ if (editorColorScheme !is TextMateColorScheme) {
+ editorColorScheme = TextMateColorScheme.create(ThemeRegistry.getInstance())
+ codeEditor.colorScheme = editorColorScheme
+ }
+ } catch (_: Exception) {
+ }
+ }
+
+ fun adaptCodeEditorScheme(scheme: EditorColorScheme) {
+ val colorScheme = if (globalClass.isDarkTheme()) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ dynamicDarkColorScheme(globalClass)
+ } else {
+ darkColorScheme()
+ }
+ } else {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ dynamicLightColorScheme(globalClass)
+ } else {
+ lightColorScheme()
+ }
+ }
+ scheme.apply {
+ setColor(
+ EditorColorScheme.LINE_NUMBER_CURRENT,
+ colorScheme.onSurface.toArgb()
+ )
+ setColor(
+ EditorColorScheme.SELECTION_HANDLE,
+ colorScheme.primary.toArgb()
+ )
+ setColor(
+ EditorColorScheme.SELECTION_INSERT,
+ colorScheme.primary.toArgb()
+ )
+ setColor(
+ EditorColorScheme.SELECTED_TEXT_BACKGROUND,
+ colorScheme.primary.copy(alpha = 0.3f).toArgb()
+ )
+ setColor(
+ EditorColorScheme.CURRENT_LINE,
+ colorScheme.surfaceContainerHigh.toArgb()
+ )
+ setColor(
+ EditorColorScheme.WHOLE_BACKGROUND,
+ colorScheme.surfaceContainerLowest.toArgb()
+ )
+ setColor(
+ EditorColorScheme.LINE_NUMBER_BACKGROUND,
+ colorScheme.surfaceContainer.toArgb()
+ )
+ setColor(
+ EditorColorScheme.LINE_NUMBER,
+ colorScheme.onSurface.copy(alpha = 0.5f).toArgb()
+ )
+ setColor(
+ EditorColorScheme.MATCHED_TEXT_BACKGROUND,
+ colorScheme.surfaceVariant.toArgb()
+ )
+ setColor(EditorColorScheme.HIGHLIGHTED_DELIMITERS_FOREGROUND, Color.Red.toArgb())
+ }
+ }
+
+ private fun analyseFile() {
+ activityTitle = uriContent.name ?: globalClass.getString(R.string.unknown)
+
+ canFormatFile = uriContent.extension.let {
+ it == jsonFileType || it == javaFileType || it == kotlinFileType
+ }
+ }
+
+ fun hideSearchPanel(codeEditor: CodeEditor) {
+ if (showSearchPanel) toggleSearchPanel(false, codeEditor)
+ }
+
+ fun toggleSearchPanel(value: Boolean = !showSearchPanel, codeEditor: CodeEditor) {
+ showSearchPanel = value
+ if (!value) codeEditor.searcher.stopSearch()
+ }
+
+ fun checkActiveFileValidity(
+ onSourceReload: (newText: String) -> Unit,
+ onFileNotFound: () -> Unit
+ ) {
+ if (!uri.exists(globalClass)) {
+ onFileNotFound()
+ return
+ }
+
+ if (uri.lastModified(globalClass) != lastModified) {
+ CoroutineScope(Dispatchers.IO).launch {
+ val newText = readSourceFile(this)
+ showSourceFileWarningDialog { onSourceReload(newText) }
+ }
+ }
+ }
+
+ fun showSourceFileWarningDialog(onConfirm: () -> Unit) {
+ warningDialogProperties.apply {
+ title = globalClass.getString(R.string.warning)
+ message = globalClass.getString(R.string.changed_source_file)
+ confirmText = globalClass.getString(R.string.reload)
+ dismissText = globalClass.getString(R.string.cancel)
+ onDismiss = { showWarningDialog = false }
+ warningDialogProperties.onConfirm = {
+ lastModified = uri.lastModified(globalClass)
+ onConfirm()
+ requireSave = false
+ showWarningDialog = false
+ }
+ showWarningDialog = true
+ }
+ }
+
+ fun updateSymbols(): List {
+ if (!customSymbolsFile.exists()) {
+ customSymbolsFile.writeText(
+ GsonBuilder().setPrettyPrinting().create().toJson(defaultSymbolHolders)
+ )
+ }
+
+ try {
+ customSymbolHolders = Gson().fromJson(
+ customSymbolsFile.readText(),
+ object : TypeToken>() {}.type
+ )
+ } catch (e: Exception) {
+ logger.logError(e)
+ globalClass.showMsg(R.string.failed_to_load_symbols_file)
+ }
+
+ return customSymbolHolders.ifEmpty { defaultSymbolHolders }
+ }
+
+ fun save(onSaved: () -> Unit, onFailed: () -> Unit) {
+ isSaving = true
+ CoroutineScope(Dispatchers.IO).launch {
+ globalClass.contentResolver.openOutputStream(uri, "wt")?.use { outputStream ->
+ BufferedWriter(OutputStreamWriter(outputStream)).use { writer ->
+ writer.write(content)
+ }
+ withContext(Dispatchers.Main) { onSaved().also { isSaving = false } }
+ } ?: withContext(Dispatchers.Main) { onFailed().also { isSaving = false } }
+ }
+ }
+
+ fun createCodeEditorView(context: Context): CodeEditor {
+ return CodeEditor(context).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+
+ props.apply {
+ globalClass.preferencesManager.textEditorPrefs.let {
+ useICULibToSelectWords = it.useICULibToSelectWords
+ symbolPairAutoCompletion = it.symbolPairAutoCompletion
+ deleteEmptyLineFast = it.deleteEmptyLineFast
+ deleteMultiSpaces = if (it.deleteMultiSpaces) -1 else 1
+ autoIndent = it.autoIndent
+ boldMatchingDelimiters = false
+ formatPastedText = true
+ isWordwrap = it.wordWrap
+ }
+ }
+
+ globalClass.preferencesManager.textEditorPrefs.let {
+ editable = !it.readOnly
+ setPinLineNumber(it.pinLineNumber)
+ getComponent(Magnifier::class.java).isEnabled = it.enableMagnifier
+ }
+
+ getComponent(EditorAutoCompletion::class.java).isEnabled = false
+
+ resetColorScheme(this, true)
+ }
+ }
+
+ fun selectionChangeListener(codeEditor: CodeEditor) {
+ activitySubtitle = getFormattedCursorPosition(codeEditor)
+ }
+
+ fun changeFontStyle(codeEditor: CodeEditor) {
+ codeEditor.typefaceText =
+ Typeface.createFromAsset(globalClass.assets, "font/JetBrainsMono-Regular.ttf")
+ }
+
+ private fun getFormattedCursorPosition(codeEditor: CodeEditor) = buildString {
+ val cursor = codeEditor.cursor
+
+ append(
+ globalClass.getString(
+ R.string.cursor_position,
+ cursor.leftLine + 1,
+ cursor.leftColumn + 1
+ )
+ )
+
+ append(whiteSpace)
+
+ if (cursor.isSelected) {
+ val selectionCount = cursor.right - cursor.left
+ append("($selectionCount)")
+ }
+
+ val searcher = codeEditor.searcher
+ if (searcher.hasQuery()) {
+ val idx = searcher.currentMatchedPositionIndex
+
+ append(whiteSpace)
+ append(globalClass.getString(R.string.text_editor_search_result))
+
+ if (idx == -1) {
+ append("${searcher.matchedPositionCount}")
+ } else {
+ append("${idx + 1}/${searcher.matchedPositionCount}")
+ }
+ }
+ }
+
+ override fun onClose() {
+
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/OptionsMenu.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/OptionsMenu.kt
new file mode 100644
index 00000000..8d1b4b40
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/OptionsMenu.kt
@@ -0,0 +1,271 @@
+package com.raival.compose.file.explorer.screen.viewer.text.ui
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.Backspace
+import androidx.compose.material.icons.automirrored.rounded.FormatAlignLeft
+import androidx.compose.material.icons.automirrored.rounded.FormatIndentIncrease
+import androidx.compose.material.icons.automirrored.rounded.WrapText
+import androidx.compose.material.icons.rounded.AirlineStops
+import androidx.compose.material.icons.rounded.Code
+import androidx.compose.material.icons.rounded.Numbers
+import androidx.compose.material.icons.rounded.RemoveRedEye
+import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material.icons.rounded.Settings
+import androidx.compose.material.icons.rounded.SpaceBar
+import androidx.compose.material.icons.rounded.TextRotationNone
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.screen.viewer.text.TextViewerInstance
+import io.github.rosemoe.sora.widget.CodeEditor
+import io.github.rosemoe.sora.widget.component.Magnifier
+import me.saket.cascade.CascadeDropdownMenu
+
+@Composable
+fun OptionsMenu(
+ textViewerInstance: TextViewerInstance,
+ expanded: Boolean,
+ codeEditor: CodeEditor,
+ onDismissRequest: () -> Unit
+) {
+ val preferences = globalClass.preferencesManager.textEditorPrefs
+
+ CascadeDropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { onDismissRequest() },
+ fixedWidth = 250.dp
+ ) {
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(id = R.string.search)) },
+ onClick = {
+ textViewerInstance.toggleSearchPanel(codeEditor = codeEditor)
+ onDismissRequest()
+ },
+ leadingIcon = { Icon(Icons.Rounded.Search, null) }
+ )
+
+ if (textViewerInstance.canFormatFile) {
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.format)) },
+ onClick = {
+ codeEditor.formatCodeAsync()
+ onDismissRequest()
+ },
+ leadingIcon = { Icon(Icons.AutoMirrored.Rounded.FormatAlignLeft, null) }
+ )
+ }
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.word_wrap)) },
+ onClick = {
+ preferences.wordWrap = (!preferences.wordWrap).also { newValue ->
+ codeEditor.isWordwrap = newValue
+ onDismissRequest()
+ }
+ },
+ leadingIcon = { Icon(Icons.AutoMirrored.Rounded.WrapText, null) },
+ trailingIcon = {
+ Checkbox(preferences.wordWrap, {
+ preferences.wordWrap = (!preferences.wordWrap).also { newValue ->
+ codeEditor.isWordwrap = newValue
+ onDismissRequest()
+ }
+ })
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.read_only)) },
+ onClick = {
+ preferences.readOnly = (!preferences.readOnly).also { newValue ->
+ codeEditor.editable = !newValue
+ onDismissRequest()
+ }
+ },
+ leadingIcon = { Icon(Icons.Rounded.RemoveRedEye, null) },
+ trailingIcon = {
+ Checkbox(preferences.readOnly, {
+ preferences.readOnly = (!preferences.readOnly).also { newValue ->
+ codeEditor.editable = !newValue
+ onDismissRequest()
+ }
+ })
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.jump_to_line)) },
+ onClick = {
+ textViewerInstance.showJumpToPositionDialog = true
+ onDismissRequest()
+ },
+ leadingIcon = { Icon(Icons.Rounded.AirlineStops, null) }
+ )
+
+ DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.preferences)) },
+ leadingIcon = { Icon(Icons.Rounded.Settings, null) },
+ children = {
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.use_icu_selection)) },
+ onClick = {
+ preferences.useICULibToSelectWords =
+ (!preferences.useICULibToSelectWords).also { newValue ->
+ codeEditor.props.useICULibToSelectWords = newValue
+ }
+ },
+ leadingIcon = { Icon(Icons.Rounded.TextRotationNone, null) },
+ trailingIcon = {
+ Checkbox(
+ checked = preferences.useICULibToSelectWords,
+ onCheckedChange = {
+ preferences.useICULibToSelectWords =
+ !preferences.useICULibToSelectWords.also { newValue ->
+ codeEditor.props.useICULibToSelectWords = newValue
+ }
+ }
+ )
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.pin_numbers_line)) },
+ onClick = {
+ preferences.pinLineNumber =
+ (!preferences.pinLineNumber).also { newValue ->
+ codeEditor.setPinLineNumber(newValue)
+ }
+ },
+ leadingIcon = { Icon(Icons.Rounded.Numbers, null) },
+ trailingIcon = {
+ Checkbox(
+ checked = preferences.pinLineNumber,
+ onCheckedChange = {
+ preferences.pinLineNumber =
+ (!preferences.pinLineNumber).also { newValue ->
+ codeEditor.setPinLineNumber(newValue)
+ }
+ }
+ )
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.auto_symbol_pair)) },
+ onClick = {
+ preferences.symbolPairAutoCompletion =
+ (!preferences.symbolPairAutoCompletion).also { newValue ->
+ codeEditor.props.symbolPairAutoCompletion = newValue
+ }
+ },
+ leadingIcon = { Icon(Icons.Rounded.Code, null) },
+ trailingIcon = {
+ Checkbox(
+ checked = preferences.symbolPairAutoCompletion,
+ onCheckedChange = {
+ preferences.symbolPairAutoCompletion =
+ (!preferences.symbolPairAutoCompletion).also { newValue ->
+ codeEditor.props.symbolPairAutoCompletion = newValue
+ }
+ }
+ )
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.delete_empty_lines)) },
+ onClick = {
+ preferences.deleteEmptyLineFast =
+ (!preferences.deleteEmptyLineFast).also { newValue ->
+ codeEditor.props.deleteEmptyLineFast = newValue
+ }
+ },
+ leadingIcon = { Icon(Icons.AutoMirrored.Rounded.Backspace, null) },
+ trailingIcon = {
+ Checkbox(
+ checked = preferences.deleteEmptyLineFast,
+ onCheckedChange = {
+ preferences.deleteEmptyLineFast =
+ (!preferences.deleteEmptyLineFast).also { newValue ->
+ codeEditor.props.deleteEmptyLineFast = newValue
+ }
+ }
+ )
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.delete_tabs)) },
+ onClick = {
+ preferences.deleteMultiSpaces =
+ (!preferences.deleteMultiSpaces).also { newValue ->
+ codeEditor.props.deleteMultiSpaces = if (newValue) -1 else 1
+ }
+ },
+ leadingIcon = { Icon(Icons.Rounded.SpaceBar, null) },
+ trailingIcon = {
+ Checkbox(
+ checked = preferences.deleteMultiSpaces,
+ onCheckedChange = {
+ preferences.deleteMultiSpaces =
+ (!preferences.deleteMultiSpaces).also { newValue ->
+ codeEditor.props.deleteMultiSpaces = if (newValue) -1 else 1
+ }
+ }
+ )
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.auto_indentation)) },
+ onClick = {
+ preferences.autoIndent = (!preferences.autoIndent).also { newValue ->
+ codeEditor.props.autoIndent = newValue
+ }
+ },
+ leadingIcon = { Icon(Icons.AutoMirrored.Rounded.FormatIndentIncrease, null) },
+ trailingIcon = {
+ Checkbox(
+ checked = preferences.autoIndent,
+ onCheckedChange = {
+ preferences.autoIndent =
+ (!preferences.autoIndent).also { newValue ->
+ codeEditor.props.autoIndent = newValue
+ }
+ }
+ )
+ }
+ )
+
+ androidx.compose.material3.DropdownMenuItem(
+ text = { Text(text = stringResource(R.string.magnifier)) },
+ onClick = {
+ preferences.enableMagnifier =
+ (!preferences.enableMagnifier).also { newValue ->
+ codeEditor.getComponent(Magnifier::class.java).isEnabled =
+ newValue
+ }
+ },
+ leadingIcon = { Icon(Icons.Rounded.Search, null) },
+ trailingIcon = {
+ Checkbox(
+ checked = preferences.enableMagnifier,
+ onCheckedChange = {
+ preferences.enableMagnifier =
+ (!preferences.enableMagnifier).also { newValue ->
+ codeEditor.getComponent(Magnifier::class.java).isEnabled =
+ newValue
+ }
+ }
+ )
+ }
+ )
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/ToolbarView.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/ToolbarView.kt
new file mode 100644
index 00000000..3405ca1c
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/text/ui/ToolbarView.kt
@@ -0,0 +1,108 @@
+package com.raival.compose.file.explorer.screen.viewer.text.ui
+
+import androidx.activity.OnBackPressedDispatcher
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material.icons.automirrored.rounded.Redo
+import androidx.compose.material.icons.automirrored.rounded.Undo
+import androidx.compose.material.icons.rounded.Menu
+import androidx.compose.material.icons.rounded.Save
+import androidx.compose.material.icons.rounded.SaveAs
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.screen.viewer.text.TextViewerInstance
+import io.github.rosemoe.sora.widget.CodeEditor
+
+@Composable
+fun ToolbarView(
+ textViewerInstance: TextViewerInstance,
+ codeEditor: CodeEditor,
+ onBackPressedDispatcher: OnBackPressedDispatcher
+) {
+ var showOptionsMenu by remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(56.dp)
+ .background(color = colorScheme.surfaceContainer)
+ .padding(horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = { onBackPressedDispatcher.onBackPressed() }) {
+ Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = null)
+ }
+
+ Column(
+ Modifier
+ .weight(1f)
+ ) {
+ Text(
+ modifier = Modifier.fillMaxWidth(),
+ text = textViewerInstance.activityTitle,
+ fontSize = 21.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+
+ IconButton(enabled = textViewerInstance.canUndo, onClick = { codeEditor.undo() }) {
+ Icon(imageVector = Icons.AutoMirrored.Rounded.Undo, contentDescription = null)
+ }
+
+ IconButton(enabled = textViewerInstance.canRedo, onClick = { codeEditor.redo() }) {
+ Icon(imageVector = Icons.AutoMirrored.Rounded.Redo, contentDescription = null)
+ }
+
+ IconButton(
+ onClick = {
+ textViewerInstance.save(
+ onSaved = {
+ globalClass.showMsg(R.string.saved)
+ textViewerInstance.requireSave = false
+ },
+ onFailed = { globalClass.showMsg(R.string.failed_to_save) }
+ )
+ }
+ ) {
+ Icon(
+ imageVector = if (textViewerInstance.requireSave) {
+ Icons.Rounded.SaveAs
+ } else {
+ Icons.Rounded.Save
+ }, contentDescription = null
+ )
+ }
+
+ IconButton(onClick = { showOptionsMenu = !showOptionsMenu }) {
+ Icon(imageVector = Icons.Rounded.Menu, contentDescription = null)
+ OptionsMenu(
+ textViewerInstance = textViewerInstance,
+ expanded = showOptionsMenu,
+ codeEditor = codeEditor
+ ) {
+ showOptionsMenu = false
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerActivity.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerActivity.kt
new file mode 100644
index 00000000..828baed1
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerActivity.kt
@@ -0,0 +1,31 @@
+package com.raival.compose.file.explorer.screen.viewer.video
+
+import android.net.Uri
+import androidx.activity.compose.setContent
+import com.raival.compose.file.explorer.screen.viewer.ViewerActivity
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+import com.raival.compose.file.explorer.screen.viewer.video.ui.VideoPlayerScreen
+import com.raival.compose.file.explorer.theme.FileExplorerTheme
+
+class VideoPlayerActivity : ViewerActivity() {
+ override fun onCreateNewInstance(
+ uri: Uri,
+ uid: String
+ ): ViewerInstance {
+ return VideoPlayerInstance(uri, uid)
+ }
+
+ override fun onReady(instance: ViewerInstance) {
+ val videoPlayerInstance = instance as VideoPlayerInstance
+ setContent {
+ FileExplorerTheme {
+ VideoPlayerScreen(
+ videoUri = videoPlayerInstance.uri,
+ videoPlayerInstance = videoPlayerInstance,
+ onBackPressed = { finish() }
+ )
+ }
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerInstance.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerInstance.kt
new file mode 100644
index 00000000..12700431
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/VideoPlayerInstance.kt
@@ -0,0 +1,138 @@
+package com.raival.compose.file.explorer.screen.viewer.video
+
+import android.content.Context
+import android.net.Uri
+import androidx.media3.common.C.TIME_UNSET
+import androidx.media3.common.MediaItem
+import androidx.media3.common.Player
+import androidx.media3.exoplayer.ExoPlayer
+import com.raival.compose.file.explorer.App.Companion.globalClass
+import com.raival.compose.file.explorer.R
+import com.raival.compose.file.explorer.common.extension.name
+import com.raival.compose.file.explorer.screen.viewer.ViewerInstance
+import com.raival.compose.file.explorer.screen.viewer.video.model.VideoPlayerState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class VideoPlayerInstance(
+ override val uri: Uri,
+ override val id: String
+) : ViewerInstance {
+ private val _playerState = MutableStateFlow(VideoPlayerState())
+ val playerState: StateFlow = _playerState.asStateFlow()
+ private var exoPlayer: ExoPlayer? = null
+ private var positionTrackingJob: Job? = null
+
+ suspend fun initializePlayer(context: Context, uri: Uri) {
+ _playerState.value = _playerState.value.copy(isLoading = true)
+
+ withContext(Dispatchers.Main) {
+ exoPlayer = ExoPlayer.Builder(context).build().apply {
+ val mediaItem = MediaItem.Builder()
+ .setUri(uri)
+ .build()
+
+ setMediaItem(mediaItem)
+ prepare()
+
+ volume = 0f
+
+ _playerState.value = _playerState.value.copy(
+ title = uri.name ?: globalClass.getString(R.string.unknown)
+ )
+
+ addListener(object : Player.Listener {
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ _playerState.value = _playerState.value.copy(isPlaying = isPlaying)
+ }
+
+ override fun onPlaybackStateChanged(playbackState: Int) {
+ _playerState.value = _playerState.value.copy(
+ isLoading = playbackState == Player.STATE_BUFFERING
+ )
+
+ if (playbackState == Player.STATE_READY) {
+ _playerState.value = _playerState.value.copy(
+ duration = duration,
+ isLoading = false
+ )
+ }
+ }
+ })
+ }
+ }
+ startPositionTracking()
+ }
+
+ private fun startPositionTracking() {
+ positionTrackingJob?.cancel()
+ positionTrackingJob = CoroutineScope(Dispatchers.Main).launch {
+ while (true) {
+ exoPlayer?.let { player ->
+ _playerState.value = _playerState.value.copy(
+ currentPosition = player.currentPosition,
+ duration = player.duration.takeIf { it != TIME_UNSET } ?: 0L
+ )
+ }
+ delay(100)
+ }
+ }
+ }
+
+ fun playPause() {
+ exoPlayer?.let { player ->
+ if (player.isPlaying) {
+ player.pause()
+ } else {
+ player.play()
+ }
+ }
+ }
+
+ fun seekTo(position: Long) {
+ exoPlayer?.seekTo(position)
+ }
+
+ fun setPlaybackSpeed(speed: Float) {
+ exoPlayer?.setPlaybackSpeed(speed)
+ _playerState.value = _playerState.value.copy(playbackSpeed = speed)
+ }
+
+ fun toggleMute() {
+ exoPlayer?.let { player ->
+ val currentVolume = player.volume
+ val newVolume = if (currentVolume > 0f) 0f else 1f
+ player.volume = newVolume
+ _playerState.value = _playerState.value.copy(isMuted = newVolume == 0f)
+ }
+ }
+
+ fun toggleControls() {
+ _playerState.value =
+ _playerState.value.copy(showControls = !_playerState.value.showControls)
+ }
+
+ fun toggleRepeatMode() {
+ val newMode = when (_playerState.value.repeatMode) {
+ Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE
+ else -> Player.REPEAT_MODE_OFF
+ }
+ exoPlayer?.repeatMode = newMode
+ _playerState.value = _playerState.value.copy(repeatMode = newMode)
+ }
+
+ fun getPlayer() = exoPlayer
+
+ override fun onClose() {
+ positionTrackingJob?.cancel()
+ exoPlayer?.release()
+ exoPlayer = null
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/model/VideoPlayerState.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/model/VideoPlayerState.kt
new file mode 100644
index 00000000..baa4e618
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/model/VideoPlayerState.kt
@@ -0,0 +1,15 @@
+package com.raival.compose.file.explorer.screen.viewer.video.model
+
+import androidx.media3.common.Player
+
+data class VideoPlayerState(
+ val isPlaying: Boolean = false,
+ val isLoading: Boolean = true,
+ val currentPosition: Long = 0L,
+ val duration: Long = 0L,
+ val playbackSpeed: Float = 1.0f,
+ val repeatMode: Int = Player.REPEAT_MODE_OFF,
+ val isMuted: Boolean = true,
+ val showControls: Boolean = true,
+ val title: String = ""
+)
diff --git a/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/ui/VideoPlayerScreen.kt b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/ui/VideoPlayerScreen.kt
new file mode 100644
index 00000000..44f85e47
--- /dev/null
+++ b/app/src/main/java/com/raival/compose/file/explorer/screen/viewer/video/ui/VideoPlayerScreen.kt
@@ -0,0 +1,392 @@
+package com.raival.compose.file.explorer.screen.viewer.video.ui
+
+import android.net.Uri
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Forward10
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Replay10
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.media3.ui.PlayerView
+import com.raival.compose.file.explorer.common.extension.toFormattedTime
+import com.raival.compose.file.explorer.screen.viewer.video.VideoPlayerInstance
+import com.raival.compose.file.explorer.screen.viewer.video.model.VideoPlayerState
+import net.engawapg.lib.zoomable.rememberZoomState
+import net.engawapg.lib.zoomable.zoomable
+import kotlin.math.abs
+
+@Composable
+fun VideoPlayerScreen(
+ videoUri: Uri,
+ videoPlayerInstance: VideoPlayerInstance,
+ onBackPressed: () -> Unit
+) {
+ val context = LocalContext.current
+ val playerState by videoPlayerInstance.playerState.collectAsState()
+
+ LaunchedEffect(videoUri) {
+ videoPlayerInstance.initializePlayer(context, videoUri)
+ }
+
+ DisposableEffect(Unit) {
+ onDispose {
+ videoPlayerInstance.onClose()
+ }
+ }
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = colorScheme.background
+ ) {
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ // Video Player View
+ if (!playerState.isLoading) {
+ AndroidView(
+ factory = { ctx ->
+ PlayerView(ctx).apply {
+ player = videoPlayerInstance.getPlayer()
+ useController = false
+ }
+ },
+ modifier = Modifier
+ .fillMaxSize()
+ .zoomable(
+ zoomState = rememberZoomState(),
+ onTap = {
+ videoPlayerInstance.toggleControls()
+ }
+ )
+ )
+ }
+
+ // Controls Overlay
+ AnimatedVisibility(
+ visible = playerState.showControls && !playerState.isLoading,
+ enter = fadeIn(),
+ exit = fadeOut()
+ ) {
+ VideoControls(
+ state = playerState,
+ onPlayPause = { videoPlayerInstance.playPause() },
+ onSeekForward = {
+ val newPos =
+ (playerState.currentPosition + 10000).coerceAtMost(playerState.duration)
+ videoPlayerInstance.seekTo(newPos)
+ },
+ onSeekBackward = {
+ val newPos = (playerState.currentPosition - 10000).coerceAtLeast(0)
+ videoPlayerInstance.seekTo(newPos)
+ },
+ onSeek = { position ->
+ videoPlayerInstance.seekTo(position)
+ },
+ onBackPressed = onBackPressed,
+ onToggleMute = { videoPlayerInstance.toggleMute() },
+ )
+ }
+
+ // Loading indicator
+ if (playerState.isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(
+ color = colorScheme.primary,
+ modifier = Modifier.size(48.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun VideoControls(
+ state: VideoPlayerState,
+ onToggleMute: () -> Unit,
+ onPlayPause: () -> Unit,
+ onSeekForward: () -> Unit,
+ onSeekBackward: () -> Unit,
+ onSeek: (Long) -> Unit,
+ onBackPressed: () -> Unit
+) {
+ val defaultColor = colorScheme.surface
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ defaultColor.copy(alpha = 0.9f),
+ Color.Transparent,
+ Color.Transparent,
+ Color.Transparent,
+ defaultColor.copy(alpha = 0.9f)
+ )
+ )
+ )
+ ) {
+ // Top bar with title and back button
+ TopBar(
+ playerState = state,
+ onBackPressed = onBackPressed,
+ onToggleMute = onToggleMute,
+ modifier = Modifier.align(Alignment.TopStart)
+ )
+
+ // Center controls
+ CenterControls(
+ isPlaying = state.isPlaying,
+ onPlayPause = onPlayPause,
+ onSeekForward = onSeekForward,
+ onSeekBackward = onSeekBackward,
+ modifier = Modifier.align(Alignment.Center)
+ )
+
+ // Bottom progress bar
+ BottomControls(
+ currentPosition = state.currentPosition,
+ duration = state.duration,
+ onSeek = onSeek,
+ modifier = Modifier.align(Alignment.BottomStart)
+ )
+ }
+}
+
+@Composable
+fun TopBar(
+ playerState: VideoPlayerState,
+ onBackPressed: () -> Unit,
+ onToggleMute: () -> Unit,
+ modifier: Modifier,
+) {
+ Row(
+ modifier = modifier
+ .windowInsetsPadding(WindowInsets.statusBars)
+ .fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(
+ onClick = onBackPressed,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ tint = colorScheme.onSurface
+ )
+ }
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ Text(
+ text = playerState.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+
+ Spacer(modifier = Modifier.width(16.dp))
+
+ IconButton(
+ onClick = onToggleMute,
+ ) {
+ Icon(
+ imageVector = if (playerState.isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = null,
+ tint = colorScheme.onSurface
+ )
+ }
+ }
+}
+
+@Composable
+fun CenterControls(
+ isPlaying: Boolean,
+ onPlayPause: () -> Unit,
+ onSeekForward: () -> Unit,
+ onSeekBackward: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Seek backward
+ IconButton(
+ onClick = onSeekBackward,
+ modifier = Modifier
+ .size(56.dp)
+ .background(
+ colorScheme.surface.copy(alpha = 0.6f),
+ CircleShape
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Replay10,
+ contentDescription = null,
+ tint = colorScheme.onSurface,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+
+ // Play/Pause
+ IconButton(
+ onClick = onPlayPause,
+ modifier = Modifier
+ .size(72.dp)
+ .background(
+ colorScheme.primary,
+ CircleShape
+ )
+ ) {
+ Icon(
+ imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
+ contentDescription = null,
+ tint = colorScheme.onPrimary.copy(alpha = 0.7f),
+ modifier = Modifier.size(36.dp)
+ )
+ }
+
+ // Seek forward
+ IconButton(
+ onClick = onSeekForward,
+ modifier = Modifier
+ .size(56.dp)
+ .background(
+ colorScheme.surface.copy(alpha = 0.6f),
+ CircleShape
+ )
+ ) {
+ Icon(
+ imageVector = Icons.Default.Forward10,
+ contentDescription = null,
+ tint = colorScheme.onSurface,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BottomControls(
+ currentPosition: Long,
+ duration: Long,
+ onSeek: (Long) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ color = Color.Transparent
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(16.dp)
+ .padding(bottom = 24.dp),
+ ) {
+ var manualPosition by remember { mutableLongStateOf(0L) }
+ var manualSeek by remember { mutableFloatStateOf(0f) }
+ var isDragging by remember { mutableStateOf(false) }
+ val progress = if (duration > 0) {
+ if (abs(currentPosition - manualPosition) < 1000) {
+ (currentPosition.toFloat() / duration.toFloat()).also {
+ manualPosition = currentPosition
+ }
+ } else manualSeek
+ } else 0f
+
+ Slider(
+ value = if (isDragging) manualSeek else progress,
+ onValueChange = {
+ isDragging = true
+ manualSeek = it
+ },
+ onValueChangeFinished = {
+ (manualSeek * duration).toLong().let { newPosition ->
+ manualPosition = newPosition
+ onSeek(newPosition)
+ }
+ isDragging = false
+ },
+ modifier = Modifier.fillMaxWidth(),
+ colors = SliderDefaults.colors(
+ thumbColor = colorScheme.primary,
+ activeTrackColor = colorScheme.primary,
+ inactiveTrackColor = colorScheme.onSurface.copy(alpha = 0.3f)
+ )
+ )
+
+ // Time labels
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = (if (isDragging) (manualSeek * duration).toLong() else manualPosition).toFormattedTime(),
+ style = MaterialTheme.typography.bodySmall,
+ color = colorScheme.onSurface
+ )
+ Text(
+ text = duration.toFormattedTime(),
+ style = MaterialTheme.typography.bodySmall,
+ color = colorScheme.onSurface
+ )
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt b/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt
index 5bb73cdd..1c24a0d5 100644
--- a/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt
+++ b/app/src/main/java/com/raival/compose/file/explorer/theme/Theme.kt
@@ -33,17 +33,17 @@ fun FileExplorerTheme(
) {
val context = LocalContext.current
val manager = globalClass.preferencesManager
- val darkTheme: Boolean = if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- if (manager.displayPrefs.theme == ThemePreference.SYSTEM.ordinal) {
+ val darkTheme: Boolean = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ if (manager.appearancePrefs.theme == ThemePreference.SYSTEM.ordinal) {
isSystemInDarkTheme()
- } else manager.displayPrefs.theme == ThemePreference.DARK.ordinal
+ } else manager.appearancePrefs.theme == ThemePreference.DARK.ordinal
} else {
- manager.displayPrefs.theme == ThemePreference.DARK.ordinal
+ manager.appearancePrefs.theme == ThemePreference.DARK.ordinal
}
fun getTheme(): ColorScheme {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- when (manager.displayPrefs.theme) {
+ when (manager.appearancePrefs.theme) {
ThemePreference.LIGHT.ordinal -> dynamicLightColorScheme(context)
ThemePreference.DARK.ordinal -> dynamicDarkColorScheme(context)
else -> if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(
@@ -51,7 +51,7 @@ fun FileExplorerTheme(
)
}
} else {
- when (manager.displayPrefs.theme) {
+ when (manager.appearancePrefs.theme) {
ThemePreference.LIGHT.ordinal -> LightColorScheme
ThemePreference.DARK.ordinal -> DarkColorScheme
else -> if (darkTheme) DarkColorScheme else LightColorScheme
@@ -63,7 +63,7 @@ fun FileExplorerTheme(
mutableStateOf(getTheme())
}
- LaunchedEffect(manager.displayPrefs.theme) {
+ LaunchedEffect(manager.appearancePrefs.theme) {
colorScheme = getTheme()
}
@@ -71,8 +71,10 @@ fun FileExplorerTheme(
if (!view.isInEditMode) {
SideEffect {
- val window = (view.context as Activity).window
- WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
+ (view.context as? Activity)?.window?.let {
+ WindowCompat.getInsetsController(it, view).isAppearanceLightStatusBars = !darkTheme
+ }
+
}
}
diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml
index 4ee702a4..2af555ea 100644
--- a/app/src/main/res/values-fa/strings.xml
+++ b/app/src/main/res/values-fa/strings.xml
@@ -1,219 +1,328 @@
-
- پرونده کاوشگر
+ مدیر فایل
ریشه
- ذخیره داخلی
- اجازه دسترسی به ذخیره سازی لازم است
- فحش
- پلاگین
- پرونده کاوشگر
- پرش به مسیر
+ حافظه داخلی
+ مجوز دسترسی به حافظه مورد نیاز است
+ ویرایشگر متن
+ مدیر فایل
+ رفتن به مسیر
مسیر مقصد
- باز
+ باز کردن
مسیر نامعتبر
- برگه جدید
- پرونده ها
+ تب جدید
سطل بازیافت
- صرفه جویی
+ در حال ذخیره…
هشدار
- برخی از پرونده ها در ویرایشگر متن ذخیره نمی شوند. آیا می خواهید اکنون آنها را نجات دهید؟
+ برخی فایلها در ویرایشگر متن ذخیره نشدهاند. آیا میخواهید آنها را الان ذخیره کنید؟
نادیده گرفتن
- پس انداز کردن
+ ذخیره
پوشه خالی
- "(٪ S انتخاب شده)"
- پرونده ها
- کار جدید اضافه شده است
+ " (%s انتخاب شده)"
+ فایلها
+ کار جدید اضافه شد
نام بسته
نام نسخه
- رمز نسخه
+ کد نسخه
اندازه
- کاوش کردن
- نصب کردن
- نشانه ها
- حذف کردن
+ کشف
+ نصب
+ نشانکها
+ حذف
خالی
- وظیفه
+ کار
جستجو
- ایجاد کردن
- مرتب
- همه را انتخاب کنید
- گزینه
- جدید ایجاد کنید
+ ایجاد
+ مرتبسازی
+ انتخاب همه
+ گزینهها
+ محتوای جدید
نام
- نام پرونده نامعتبر
- پرونده
- ایجاد پرونده انجام نشد
- پرونده با نام مشابه از قبل وجود دارد
+ نام فایل نامعتبر
+ فایل
+ ایجاد فایل ناموفق بود
+ فایل با نام مشابه وجود دارد
پوشه
- ایجاد پوشه انجام نشد
+ ایجاد پوشه ناموفق بود
نام پوشه نامعتبر
- تأیید کردن
- برکناری
- تأیید را حذف کنید
- حرکت به سطل بازیافت
- بایگانی ایجاد کنید
- ایجاد پرونده امکان پذیر نیست
- لغو کردن
- به نشانک ها اضافه شده است
- در برگه جدید باز کنید
- باز کردن
- به صفحه اصلی اضافه کنید
- با ویرایشگر متن ویرایش کنید
- فشردن
- فشردن
- جزئیات
- خواص
- مسیر
- اری
- تاریخ اصلاح
- والدین
+ تأیید
+ بستن
+ تأیید حذف
+ انتقال به سطل بازیافت
+ ایجاد آرشیو
+ لغو
+ به نشانکها اضافه شد
+ باز کردن در تب جدید
+ باز کردن با
+ اضافه کردن به صفحه اصلی
+ ویرایش با ویرایشگر متن
+ فشردهسازی
انتخاب شده
- محتوا
- کل: ٪ 1 $ D (پرونده ها: ٪ 2 $ D | پوشه ها: ٪ 3 $ D)
- کل: ٪ 1 $ D (پرونده ها: ٪ 2 $ D | پوشه ها: ٪ 3 $ D)
- کپی شده در کلیپ بورد
- مرتب
- فقط روی این پوشه اقدام کنید
- نام (A-Z)
+ در کلیپبورد کپی شد
+ مرتبسازی بر اساس
+ فقط روی این پوشه اعمال کن
+ نام (الف-ی)
تاریخ (جدیدتر)
- اندازه (کوچکتر)
- پوشه ها اول
+ اندازه (کوچکتر)
+ پوشهها اول
معکوس
- نوع تقلید
- نزدیک
- دیگران را ببندید
- همه را ببندید
- نام بردن
- مسیر کپی
- در محل قرار گرفتن
+ نوع MIME
+ بستن
+ بستن بقیه
+ تغییر نام
+ کپی مسیر
+ یافتن
نتایج
- پرس و جو جستجو
- وظایف
- وظیفه نامعتبر
- هیچ برنامه ای پیدا نکرد که بتواند این نوع پرونده ها را باز کند
- این پرونده را باز نکرد
- کنترل مکان نما سمت چپ
- کنترل مکان نما درست
- پرش به موقعیت
- خط: ستون (به عنوان مثال 23:11)
- رفتن
+ درخواست جستجو
+ کارها
+ هیچ برنامهای برای باز کردن این نوع فایل یافت نشد
+ باز کردن این فایل ناموفق بود
+ کنترل نشانگر چپ
+ کنترل نشانگر راست
+ رفتن به موقعیت
+ خط:ستون (مثلاً 23:11)
+ برو
موقعیت نامعتبر
قالب
- بسته بندی
- فقط بخوانید
- پرش به خط
- ترجیحات
- از انتخاب ICU استفاده کنید
- خط شماره پین
+ بستن کلمات
+ فقط خواندنی
+ رفتن به خط
+ تنظیمات
+ استفاده از انتخاب ICU
+ سنجاق کردن خط شمارهها
جفت نماد خودکار
- خطوط خالی را حذف کنید
- زبانه ها را حذف کنید
+ حذف خطوط خالی
+ حذف تبها
تورفتگی خودکار
- بزرگ کننده
- پرونده های اخیر
- این پرونده در حال حاضر باز است
- پیدا کردن
- تعویض کردن
- مورد حساس
- مگس
+ ذرهبین
+ فایلهای اخیر
+ این فایل در حال حاضر باز است
+ یافتن
+ جایگزین کردن
+ عبارت منظم
+ حساس به حروف کوچک و بزرگ
+ جایگزین
همه
- سعادت
- طرف دیگر
- ذخیره شده
- پس انداز نشد
- ایجاد پرونده موقت انجام نشد
- بارگیری پرونده نمادها انجام نشد
- پرونده منبع تغییر کرده است ، آیا می خواهید آن را بارگیری مجدد کنید؟
- بارگیری مجدد
+ قبلی
+ بعدی
+ ذخیره شد
+ ذخیره ناموفق بود
+ بارگذاری فایل نمادها ناموفق بود
+ فایل منبع تغییر کرده است، آیا میخواهید آن را دوباره بارگذاری کنید؟
+ بارگذاری مجدد
نادیده گرفتن تغییرات
- این پرونده ذخیره نشده است ، آیا می خواهید آن را ذخیره کنید؟
- پرونده یافت نشد
- پرونده دیگر وجود ندارد
- حرکت
- آماده سازی ...
- شروع کار را شروع کنید
- پیشرفت: ٪ 1 $ d/٪ 2 $ d
- در حال حرکت: ٪ 1 $ s \ ndestination: ٪ 2 $ s
- حذف پرونده های منبع…
- انجام شده
- کپی کردن
- کپی: ٪ 1 $ s \ ndestination: ٪ 2 $ s
- پیشرفت: ٪ 1 $ D
- حذف: ٪ 1 $ s
- فشرده سازی: ٪ 1 $ s
- فشرده سازی: $ 1 $ s
- ترجیحات
- نمایش
- موضوع
- ترجیح تم را انتخاب کنید
- سبک
- تاریک
- سیستم را دنبال کنید
+ این فایل ذخیره نشده است، آیا میخواهید آن را ذخیره کنید؟
+ فایل یافت نشد
+ فایل دیگر وجود ندارد
+ انتقال
+ در حال آمادهسازی
+ در حال حذف فایلهای منبع…
+ کپی
+ در حال حذف
+ در حال فشردهسازی
+ تنظیمات
+ پوسته
+ انتخاب ترجیح پوسته
+ روشن
+ تیره
+ دنبال کردن سیستم
ویرایشگر متن
- پرونده های اخیر محدود است
- حداکثر تعداد پرونده های اخیر را برای نمایش انتخاب کنید
- اندازه لیست پرونده
+ محدودیت فایلهای اخیر
+ حداکثر تعداد فایلهای اخیر برای نمایش را انتخاب کنید
+ اندازه فهرست فایل
کوچک
- واسطه
+ متوسط
بزرگ
- فوق العاده بزرگ
- یک اندازه مناسب برای لیست پرونده ها انتخاب کنید
- برچسب های نوار گزینه های پایین را نشان دهید
+ خیلی بزرگ
+ اندازه مناسب برای فهرست فایل انتخاب کنید
+ نمایش برچسبهای نوار پایین
عمومی
- پرونده ها محدودیت جستجو
- تعداد نتایج نشان داده شده هنگام جستجو را محدود کنید
- شمارش ستون لیست پرونده ها
- تعداد ستون ها را انتخاب کنید
- بیننده PDF
- ناشناخته
- تعداد صفحه: ٪ 1 $ D
- تصویر
- بیننده رسانه
- پرونده رسانه ای نامعتبر
- هنرمند ناشناخته
- جستجو به پایان رسید
- شمارش محتوای پوشه را نشان دهید
- نمایش پرونده های پنهان
- آیا واقعاً می خواهید پرونده های انتخاب شده را حذف کنید؟
- اندازه: ٪ 1 $ D
- ٪ 1 $ D: ٪ 2 $ D
- "نتیجه جستجو:"
- پوشه ها: ٪ 1 $ D
- پرونده ها: ٪ 1 $ d
+ محدودیت جستجو در فایلها
+ محدود کردن تعداد نتایج نمایش داده شده هنگام جستجو
+ تعداد ستون فهرست فایلها
+ تعداد ستونها را انتخاب کنید
+ نمایشگر PDF
+ نامشخص
+ تعداد صفحه
+ نمایشگر تصویر
+ نمایشگر رسانه
+ فایل رسانه نامعتبر
+ هنرمند نامشخص
+ نمایش تعداد محتوای پوشه
+ نمایش فایلهای مخفی
+ آیا واقعاً میخواهید فایلهای انتخاب شده را حذف کنید؟
+ محتوا: %1$d
+ %1$d:%2$d
+ "نتیجه جستجو: "
+ پوشهها: %1$d
+ فایلها: %1$d
خانه
خانه
- دسته
+ دستهبندیها
تصاویر
- فیلم
- قصور
+ ویدیوها
+ صوتیها
اسناد
- بایگانی
- انباره
- وظایف در اینجا قابل اجرا نیست
- برنامه
- برنامه
- برنامه
+ آرشیوها
+ حافظه
+ کارها نمیتوانند اینجا اجرا شوند
+ برنامهها
+ برنامهها
+ برنامهها
کاربر
سیستم
بیشتر
- تبدیل به APK
- AUTO SIGN APK
- پس از عملیات APK APK را امضا کنید
- ناموفق
- ادغام موفقیت آمیز!
- ادغام کامل
- اولیه سازی ادغام ...
- استخراج پرونده ها
- ماژول های ادغام ...
- نهایی کردن ادغام ...
- ادغام ...
- ادغام apks ...
- ادغام موفقیت آمیز نبود!
- امضای APK ...
- امضا شکست خورد!
- ادغام شکست خورد: ٪ 1 $ s
- لوب
- درخواست کردن
- هیچ پرونده اخیر وجود ندارد
+ گیتهاب
+ اعمال
+ هیچ فایل اخیری نیست
این انتخاب را به خاطر بسپار
-
+ ریشه
+ در حال شمارش فایلها…
+ در حال کپی
+ فایل %1$s در پوشه مقصد وجود دارد.
+ مخفی کردن
+ تعارض
+ رد کردن
+ کار آماده نیست.
+ کار یافت نشد.
+ اعمال روی تعارضهای بعدی
+ این پوشه غیرقابل دسترس است
+ فایلهای مخفی را در تنظیمات نمایش دهید تا همه فایلها را ببینید
+ هیچ فایل منبعی یافت نشد.
+ مشکلی پیش آمد: \n%1$s
+ مقصد وجود نداشت.
+ "در حال استخراج "
+ دایرکتوری مقصد نامعتبر.
+ ایجاد و باز کردن
+ "در حال تغییر نام"
+ تغییر نام دستهای
+ نام جدید
+ متن برای یافتن
+ جایگزین با
+ عبارت منظم
+ پیشنمایش
+ پسوند
+ آخرین تغییر
+ افزایش عددی
+ افزایش عددی با صفر
+ یافتن و جایگزین کردن
+ نحو
+ پسوند با نقطه
+ خطا در ایجاد پوشه سطل بازیافت
+ فایل zip نامعتبر
+ فشار طولانی برای نمایش منوی گزینههای فایل
+ ویژگیهای فایل
+ ویژگیهای انتخاب
+ در حال بارگذاری ویژگیها…
+ در حال تجزیه و تحلیل فایلها…
+ مکان
+ نوع
+ تغییر یافته
+ سیستم
+ مالک
+ مجوزها
+ تجزیه و تحلیل
+ محتویات
+ چکسام MD5
+ خلاصه انتخاب
+ %1$d مورد
+ اندازه کل
+ محتویات کل
+ در دسترس نیست
+ در حال محاسبه…
+ %1$d فایل، %2$d پوشه
+ خطا در محاسبه
+ اضافه کردن به نشانکها
+ عملیات فایل
+ ظاهر
+ غیرفعال کردن کشیدن پایین برای تازهسازی
+ رد کردن صفحه اصلی هنگام بستن تبها
+ فهرست فایل
+ رفتار
+ نامحدود
+ خیلی کوچک
+ کارهای نامعتبر
+ کارهای در انتظار
+ کارهای ناموفق
+ کارهای متوقف شده
+ این کار در حال اجرا است
+ کارهای در حال اجرا
+ هیچ کاری در دسترس نیست
+ هیچ نشانکی در دسترس نیست
+ توقف
+ کار ناموفق بود
+ کار متوقف شد
+ کار تکمیل شد
+ در حال توقف…
+ دسترسی به حافظه مورد نیاز است
+ برای مدیریت فایلهایتان، ما به دسترسی به حافظه دستگاهتان نیاز داریم. این به شما امکان ذخیره، سازماندهی و دسترسی یکپارچه به محتوایتان را میدهد.
+ ذخیره فایلها
+ ذخیره و سازماندهی اسنادتان
+ دسترسی به پوشهها
+ مرور و مدیریت ساختار فایلهایتان
+ اعطای دسترسی
+ فعلاً رد کن
+ مسیر
+ PDF نامعتبر
+ بارگذاری PDF ناموفق بود
+ صفحه %1$d
+ در حال بارگذاری PDF…
+ خطا
+ برگشت
+ اطلاعات PDF
+ نمایشگر متن
+ ویرایش با…
+ هیچ برنامهای برای ویرایش این تصویر یافت نشد
+ در حال بارگذاری تصویر…
+ بارگذاری تصویر ناموفق بود
+ وجود فایل را بررسی کنید و دوباره تلاش کنید
+ تلاش مجدد
+ ابعاد
+ هیچ اطلاعاتی در دسترس نیست
+ پخشکننده صوتی
+ عنوان نامشخص
+ آلبوم نامشخص
+ صدا
+ اکولایزر
+ بازنشانی
+ انجام شد
+ گزارشها
+ پاک کردن گزارشها
+ آیا مطمئن هستید که میخواهید همه گزارشها را پاک کنید؟ این عمل قابل بازگشت نیست.
+ پاک کردن
+ هیچ گزارشی در دسترس نیست
+ هیچ خطایی ثبت نشده
+ هیچ هشداری ثبت نشده
+ هیچ گزارش اطلاعاتی در دسترس نیست
+ عالی! هیچ خطایی رخ نداده است
+ هیچ هشداری ثبت نشده است
+ هیچ گزارش اطلاعاتی ثبت نشده است
+ در حال پاک کردن گزارشها…
+ ترکیب منبع/مقصد پشتیبانی نمیشود
+ خطای نامشخص
+ کپی فایلها به فایل زیپ ناموفق بود
+ استخراج فایلها از فایل زیپ ناموفق بود
+ کپی ورودیهای فایل زیپ ناموفق بود
+ تبدیل به APK
+ فایل بسته APK نامعتبر
+ در حال ادغام
+ در حال امضا
+ فایل بسته APK با موفقیت به APK تبدیل شد
+ تبدیل بسته APK به APK ناموفق بود
+ امضای فایل APK ادغام شده ناموفق بود
+ امضای خودکار فایلهای بسته APK ادغام شده
+ امضای خودکار فایلهای بسته APK ادغام شده با کلید آزمایشی
+ سفارشی کردن تب خانه
+ سفارشی کردن تب خانه
+ فایلهای اخیراً تغییر یافته
+ دستهبندیهای دسترسی سریع
+ دستگاههای ذخیرهسازی و مکانها
+ فایلها و پوشههای نشانکگذاری شده
+ فایلهای حذف شده
+ ناوبری سریع مسیر
+ اعلان اجرای کار
+ کارها در حال اجرا هستند…
+ کار نمیتواند از وضعیت فعلی ادامه یابد: %1$s
+ اعتبارسنجی کار ناموفق بود
+ کار در حالت انتظار نیست
+ اقدام مورد نیاز
+
\ No newline at end of file
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 4d779950..b6790626 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -1,219 +1,328 @@
- File Explorer
- Raiz
- Armazenamento Interno
- É necessário permissão de acesso ao armazenamento
- Editor de Texto
- Plugins
- Explorador de Arquivos
- Ir para o caminho
- Caminho de destino
- Abrir
- Caminho inválido
- Nova Aba
- Arquivos do App
- Lixeira
- Salvando…
- Aviso
- Alguns arquivos no editor de texto não estão salvos. Deseja salvá-los agora?
- Ignorar
- Salvar
- pasta vazia
- " (%s selecionados)"
+ Explorador de Arquivos
+ Raiz
+ Armazenamento Interno
+ Permissão de acesso ao armazenamento é necessária
+ Editor de Texto
+ Explorador de Arquivos
+ Ir para caminho
+ Caminho de destino
+ Abrir
+ Caminho inválido
+ Nova Aba
+ Lixeira
+ Salvando…
+ Aviso
+ Alguns arquivos no editor de texto não foram salvos. Deseja salvá-los agora?
+ Ignorar
+ Salvar
+ pasta vazia
+ " (%s selecionados)"
Arquivos
- Nova tarefa foi adicionada
- Nome do Pacote
- Nome da Versão
- Código da Versão
- Tamanho
- Explorar
- Instalar
- Favoritos
- Excluir
- Vazio
- Tarefa
- Buscar
- Criar
- Ordenar
- Selecionar Todos
- Opções
- Criar Novo
- Nome
- Nome de arquivo inválido
- Arquivo
- Falha ao criar arquivo
- Um arquivo com nome semelhante já existe
- Pasta
- Falha ao criar pasta
- Nome de pasta inválido
- Confirmar
- Descartar
- Confirmação de Exclusão
- Mover para a Lixeira
- Criar Arquivo
- Não foi possível criar o arquivo
- Cancelar
- Adicionado aos favoritos
- Abrir em nova aba
- Abrir com
- Adicionar à tela inicial
- Editar com editor de texto
- Comprimir
- Descomprimir
- Detalhes
- Propriedades
- caminho
- uri
- data de modificação
- pai
- selecionado
- conteúdo
- total: %1$d (arquivos: %2$d | pastas: %3$d)
- total: %1$d (arquivos: %2$d | pastas: %3$d)
- Copiado para a área de transferência
- Ordenar por
- Aplicar apenas a esta pasta
- Nome (A-Z)
- Data (Mais recente)
- Tamanho (menor)
- Pastas primeiro
- Inverter
- Tipo MIME
- Fechar
- Fechar outros
- Fechar tudo
- Renomear
- Copiar caminho
- Localizar
- Resultados
- Consulta de Pesquisa
- Tarefas
- Tarefa inválida
- Nenhum app pode abrir este tipo de arquivo
- Falha ao abrir este arquivo
- Controlando cursor esquerdo
- Controlando cursor direito
- Ir para posição
- Linha:Coluna (ex.: 23:11)
- Ir
- Posição inválida
- Formatar
- Quebra automática de linha
- Somente leitura
- Ir para linha
- Preferências
- Usar seleção ICU
- Fixar linha de números
- Par de símbolos automático
- Excluir linhas vazias
- Excluir tabulações
- Indentações automáticas
- Lupa
- Arquivos Recentes
- Este arquivo está atualmente aberto
- Localizar
- Substituir
- Regex
- Sensível a maiúsculas e minúsculas
- REP
- TODOS
- ANTERIOR
- PRÓXIMO
- Salvo
- Falha ao salvar
- Falha ao criar arquivo temporário
- Falha ao carregar arquivo de símbolos
- O arquivo fonte foi alterado, deseja recarregá-lo?
- Recarregar
- Ignorar alterações
- Este arquivo não foi salvo, deseja salvá-lo?
- Arquivo
- O arquivo não existe mais
- Mover
- Preparando…
- Iniciando tarefa de movimentação…
- Progresso: %1$d/%2$d
- Movendo: %1$s\nDestino: %2$s
- Excluindo arquivos de origem…
- Concluído
- Copiar
- Copiando: %1$s\nDestino: %2$s
- Progresso: %1$d
- Excluindo: %1$s
- Descompactando: %1$s
- Compactando: %1$s
- Preferências
- Exibição
- Tema
- Selecione a preferência de tema
- Claro
- Escuro
- Seguir sistema
- Editor de Texto
- Limite de arquivos recentes
- Escolha o número máximo de arquivos recentes para exibir
- Tamanho da lista de arquivos
- Pequeno
- Médio
- Grande
- Extra Grande
- Selecione um tamanho adequado para a lista de arquivos
- Mostrar rótulos na barra inferior de opções
- Geral
- Limite de pesquisa em arquivos
- Limite de resultados exibidos ao pesquisar
- Número de colunas da lista de arquivos
- Escolha o número de colunas
- Visualizador de PDF
- Desconhecido
- Contagem de Páginas: %1$d
- Visualizador de Imagens
- Visualizador de Mídia
- Arquivo de mídia inválido
- Artista Desconhecido
- Busca finalizada
- Mostrar contagem de conteúdo da pasta
- Mostrar arquivos ocultos
- Você realmente deseja excluir os arquivos selecionados?
- Tamanho: %1$d
- %1$d:%2$d
- "resultado da pesquisa: "
- pastas: %1$d
- arquivos: %1$d
- Início
- Início
- Categorias
- Imagens
- Vídeos
- Áudios
- Documentos
- Arquivos
- Armazenamento
- As tarefas não podem ser executadas aqui
- Aplicativos
- Aplicativos
- Aplicativos
- Usuário
- Sistema
- Mais
- Converter para APK
- Assinar APK automaticamente
- Assinar APK após operações de APK
- Falha
- Mesclagem bem-sucedida!
- Mesclagem concluída
- Inicializando mesclagem…
- Extraindo arquivos…
- Mesclando módulos…
- Finalizando mesclagem…
- Mesclando…
- Mesclando APKs…
- A mesclagem não foi bem-sucedida!
- Assinando APK…
- Falha ao assinar!
- Falha na mesclagem: %1$s
- Github
- Aplicar
- Nenhum Arquivo Recente
- Lembre-se desta escolha
-
+ Nova tarefa foi adicionada
+ Nome do Pacote
+ Nome da Versão
+ Código da Versão
+ Tamanho
+ Explorar
+ Instalar
+ Favoritos
+ Excluir
+ Vazio
+ Tarefa
+ Pesquisar
+ Criar
+ Classificar
+ Selecionar Tudo
+ Opções
+ Novo Conteúdo
+ Nome
+ Nome de arquivo inválido
+ Arquivo
+ Falha ao criar arquivo
+ Arquivo com nome similar já existe
+ Pasta
+ Falha ao criar pasta
+ Nome de pasta inválido
+ Confirmar
+ Dispensar
+ Confirmação de Exclusão
+ Mover para a Lixeira
+ Criar Arquivo
+ Cancelar
+ Adicionado aos favoritos
+ Abrir em nova aba
+ Abrir com
+ Adicionar à tela inicial
+ Editar com editor de texto
+ Comprimir
+ selecionado
+ Copiado para a área de transferência
+ Classificar por
+ Aplicar apenas a esta pasta
+ Nome (A-Z)
+ Data (Mais Recente)
+ Tamanho (menor)
+ Pastas primeiro
+ Reverso
+ Tipo MIME
+ Fechar
+ Fechar outros
+ Renomear
+ Copiar caminho
+ Localizar
+ Resultados
+ Consulta de Pesquisa
+ Tarefas
+ Não foi possível encontrar um aplicativo que possa abrir este tipo de arquivo
+ Falha ao abrir este arquivo
+ Controlando cursor esquerdo
+ Controlando cursor direito
+ Ir para posição
+ Linha:Coluna (ex. 23:11)
+ Ir
+ Posição inválida
+ Formato
+ Quebra de linha
+ Somente leitura
+ Ir para linha
+ Preferências
+ Usar seleção ICU
+ Fixar linha de números
+ Par de símbolos automático
+ Excluir linhas vazias
+ Excluir abas
+ Indentação automática
+ Lupa
+ Arquivos Recentes
+ Este arquivo está atualmente aberto
+ Encontrar
+ Substituir
+ Regex
+ Sensível a maiúsculas
+ SUBST
+ TODOS
+ ANTERIOR
+ PRÓXIMO
+ Salvo
+ Falha ao salvar
+ Falha ao carregar arquivo de símbolos
+ O arquivo fonte foi alterado, deseja recarregá-lo?
+ Recarregar
+ Ignorar alterações
+ Este arquivo não foi salvo, deseja salvá-lo?
+ Arquivo não encontrado
+ Arquivo não existe mais
+ Mover
+ Preparando
+ Excluindo arquivos fonte…
+ Copiar
+ Excluindo
+ Comprimindo
+ Preferências
+ Tema
+ Selecionar preferência de tema
+ Claro
+ Escuro
+ Seguir sistema
+ Editor de Texto
+ Limite de arquivos recentes
+ Escolha o número máximo de arquivos recentes para mostrar
+ Tamanho da lista de arquivos
+ Pequeno
+ Médio
+ Grande
+ Extra grande
+ Selecione um tamanho adequado para a lista de arquivos
+ Mostrar rótulos da barra inferior
+ Geral
+ Limite de busca em arquivos
+ Limitar o número de resultados mostrados na busca
+ Contagem de colunas da lista de arquivos
+ Escolher número de colunas
+ Visualizador de PDF
+ Desconhecido
+ Contagem de Páginas
+ Visualizador de Imagens
+ Visualizador de Mídia
+ Arquivo de mídia inválido
+ Artista Desconhecido
+ Mostrar contagem de conteúdo da pasta
+ Mostrar arquivos ocultos
+ Você realmente deseja excluir os arquivos selecionados?
+ Conteúdo: %1$d
+ %1$d:%2$d
+ "resultado da pesquisa: "
+ pastas: %1$d
+ arquivos: %1$d
+ Início
+ Início
+ Categorias
+ Imagens
+ Vídeos
+ Áudios
+ Documentos
+ Arquivos
+ Armazenamento
+ Tarefas não podem ser executadas aqui
+ Apps
+ Apps
+ Apps
+ Usuário
+ Sistema
+ Mais
+ Github
+ Aplicar
+ Nenhum Arquivo Recente
+ Lembrar desta escolha
+ Raiz
+ Contando arquivos…
+ Copiando
+ Arquivo %1$s já existe na pasta de destino.
+ Ocultar
+ Conflito
+ Pular
+ Tarefa não está pronta.
+ Tarefa não encontrada.
+ Aplicar a conflitos subsequentes
+ Esta pasta está inacessível
+ Mostrar arquivos ocultos nas configurações para ver todos os arquivos
+ Nenhum arquivo fonte foi encontrado.
+ Algo deu errado: \n%1$s
+ O destino estava ausente.
+ "Extraindo "
+ Diretório de destino inválido.
+ Criar e abrir
+ "Renomeando"
+ Renomear em Lote
+ Novo Nome
+ Texto para encontrar
+ Substituir por
+ Regex
+ Visualizar
+ Extensão
+ Última modificação
+ Incremento numérico
+ Incremento numérico com zeros
+ Encontrar e substituir
+ Sintaxe
+ Extensão com ponto
+ Erro ao criar pasta da lixeira
+ Arquivo zip inválido
+ Pressione longamente para mostrar menu de opções do arquivo
+ Propriedades do Arquivo
+ Propriedades da Seleção
+ Carregando Propriedades…
+ Analisando arquivos…
+ Localização
+ Tipo
+ Modificado
+ Sistema
+ Proprietário
+ Permissões
+ Análise
+ Conteúdo
+ Checksum MD5
+ Resumo da Seleção
+ %1$d itens
+ Tamanho Total
+ Conteúdo Total
+ N/D
+ Calculando…
+ %1$d arquivos, %2$d pastas
+ Erro ao calcular
+ Adicionar aos favoritos
+ Operação de Arquivo
+ Aparência
+ Desabilitar puxar para atualizar
+ Pular tela inicial ao fechar abas
+ Lista de Arquivos
+ Comportamento
+ Ilimitado
+ Extra pequeno
+ Tarefas inválidas
+ Tarefas pendentes
+ Tarefas falhadas
+ Tarefas pausadas
+ Esta tarefa está sendo executada
+ Tarefas em execução
+ Nenhuma tarefa disponível
+ Nenhum favorito disponível
+ Parar
+ Tarefa falhou
+ Tarefa foi pausada
+ Tarefa concluída
+ Parando…
+ Acesso ao Armazenamento Necessário
+ Para gerenciar seus arquivos, precisamos acessar o armazenamento do seu dispositivo. Isso permite salvar, organizar e acessar seu conteúdo perfeitamente.
+ Salvar Arquivos
+ Salvar e organizar seus documentos
+ Acessar Pastas
+ Navegar e gerenciar sua estrutura de arquivos
+ Conceder Acesso
+ Pular por agora
+ Caminho
+ PDF inválido
+ Falha ao carregar PDF
+ Página %1$d
+ Carregando PDF…
+ Erro
+ Voltar
+ Informações do PDF
+ Visualizador de Texto
+ Editar com…
+ Nenhum app encontrado para editar esta imagem
+ Carregando imagem…
+ Falha ao carregar imagem
+ Verifique se o arquivo existe e tente novamente
+ Tentar Novamente
+ Dimensões
+ Nenhuma informação disponível
+ Player de Áudio
+ Título Desconhecido
+ Álbum Desconhecido
+ Volume
+ Equalizador
+ Redefinir
+ Concluído
+ Logs
+ Limpar logs
+ Tem certeza de que deseja limpar todos os logs? Esta ação não pode ser desfeita.
+ Limpar
+ Nenhum log disponível
+ Nenhum erro registrado
+ Nenhum aviso registrado
+ Nenhum log de informação disponível
+ Ótimo! Nenhum erro foi encontrado
+ Nenhum aviso foi registrado
+ Nenhum log de informação foi registrado
+ Limpando logs…
+ Combinação origem/destino não suportada
+ Erro desconhecido
+ Falha ao copiar arquivos para o zip
+ Falha ao extrair arquivos do zip
+ Falha ao copiar entradas do zip
+ Converter para APK
+ Arquivo de pacote APK inválido
+ Mesclando
+ Assinando
+ Arquivo de pacote APK convertido com sucesso para APK
+ Falha ao converter pacote APK para APK
+ Falha ao assinar o arquivo APK mesclado
+ Assinar automaticamente arquivos de pacote APK mesclados
+ Assinar automaticamente arquivos de pacote APK mesclados com uma chave de teste
+ Personalizar aba inicial
+ Personalizar Aba Inicial
+ Arquivos modificados recentemente
+ Categorias de acesso rápido
+ Dispositivos de armazenamento e locais
+ Arquivos e pastas marcados
+ Arquivos excluídos
+ Navegação rápida de caminho
+ Notificação de Tarefa em Execução
+ Tarefas estão em execução…
+ A tarefa não pode ser continuada do estado atual: %1$s
+ Falha na validação da tarefa
+ A tarefa não está em estado pendente
+ Ação necessária
+
\ No newline at end of file
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index d2741b21..eae9dc7f 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -1,219 +1,328 @@
-
- File Explorer
- Корневой раздел
- Внутренняя память
- Требуется разрешение на доступ к хранилищу
- Текстовый редактор
- Плагины
- File Explorer
- Перейти по пути
- Путь назначения
- Открыть
- Путь некорректен
- Новая вкладка
- Файлы приложения
- Корзина
- Сохранение…
- Внимание
- Некоторые файлы в текстовом редакторе не были сохранены. Хотите сделать это сейчас?
- Игнорировать
- Сохранить
- пустая папка
- " (выбрано %s)"
- Файлы
- Добавлена новая задача
- Название пакета
- Название версии
- Номер сборки
- Размер
- Исследовать
- Установить
- Закладки
- Удалить
- Пусто
- Задача
- Поиск
- Создать
- Порядок
- Выбрать все
- Опции
- Создать
- Название
- Название файла некорректно
- Файл
- Не удалось создать файл
- Файл с таким же названием уже существует
- Папку
- Не удалось создать папку
- Название папки некорректно
- Подтвердить
- Отменить
- Подтвердите удаление
- Поместить в Корзину
- Создать архив
- Не удалось создать файл
- Отмена
- Добавлено в закладки
- Открыть в новой вкладке
- Открыть с помощью...
- Добавить на главный экран
- Открыть в редакторе текста
- Сжать
- Извлечь
- Подробности
- Свойства
- Путь
- Ссылка
- Дата изменения
- Родительская директория
- Выбрано
- Содержит
- Всего: %1$d (файлов: %2$d | папок: %3$d)
- Всего: %1$d (файлов: %2$d | папок: %3$d)
- Скопировано в буфер обмена
- Упорядочить по
- Только для этой папки
- Названию (А-Я)
- Дате (сначала новые)
- Размеру (сначала малые)
- Показывать папки первыми
- Обратный порядок
- MIME-тип
- Закрыть
- Закрыть остальные
- Закрыть все
- Переименовать
- Скопировать путь
- К расположению
- Результаты
- Поисковой запрос
- Задачи
- Задача некорректна
- Не найдено ни одного приложения, способного открыть файл этого типа
- Не удалось открыть файл
- Controlling left cursor
- Controlling right cursor
- Перейти на позицию
- Строка:Столбец (например 23:11)
- Перейти
- Позиция некорректна
- Отформатировать
- Перенос строк
- Режим просмотра
- Перейти к строке
- Предпочтения
- Использовать выделение ICU
- Закрепить номера строк
- Авто-связка парных символов
- Удалить пустые строки
- Удалить табуляции
- Авто-добавление отступов
- Лупа при выделении
- Недавние файлы
- Этот файл уже открыт
- Найти
- Заменить
- Регулярное выражение
- Учитывать регистр
- ЗАМ
- ВСЕ
- ПРЕД
- СЛЕД
- Сохранено
- Не удалось сохранить
- Не удалось создать временный файл
- Не удалось загрузить файл символов
- Целевой файл был изменён, хотите его перезагрузить?
- Перезагрузить
- Игнорировать изменения
- Файл не был сохранён, хотите его сохранить?
- Файл не найден
- Файла больше не существует
- Перемещение
- Подготовка…
- Запуск задачи на перемещение…
- Прогресс: %1$d/%2$d
- Перемещается: %1$s\nНазначение: %2$s
- Удаление исходных файлов…
- Сделано
- Копирование
- Копируется: %1$s\nНазначение: %2$s
- Прогресс: %1$d
- Удаляется: %1$s
- Извлекается: %1$s
- Архивируется: $%1$s
- Предпочтения
- Оформление
- Тема
- Выберите предпочтительную тему
- Светлая
- Тёмная
- Как в системе
- Редактор текста
- Лимит нелавних файлов
- Выберите максимальное кол-во недавних файлов для показа
- Размер шрифта в списке файлов
- Малый
- Средний
- Большой
- Огромный
- Выберите подходящий размер для списка файлов
- Подписывать кнопки в нижней панели
- Основное
- Предел результатов поиска
- Ограничить кол-во показываемых при поиске результатов
- Столбцов в списке файлов
- Выберите количество столбцов
- Просмотр PDF
- Неизвестно
- Кол-во страниц: %1$d
- Просмотр фото
- Просмотр медиа
- Медиафайл повреждён или не поддерживается
- Неизвестный исполнитель
- Поиск завершён
- Показывать кол-во содержимого папок
- Показывать скрытые файлы
- Вы действительно хотите удалить выбранные файлы?
- Размер: %1$d
- %1$d:%2$d
- "результат поиска: "
- папок: %1$d
- файлов: %1$d
- Главная
- Главная
- Категории
- Изображения
- Видео
- Аудио
- Документы
- Архивы
- Хранилище
- Задачи не могут быть выполнены здесь
- Приложения
- Приложения
- Приложения
- Пользователь
- Система
- Ещё
- Конвертировать в APK
- Автоподпись APK
- Подписать APK после операций с APK
- Ошибка
- Объединение успешно!
- Объединение завершено
- Инициализация объединения…
- Извлечение файлов…
- Объединение модулей…
- Завершение объединения…
- Объединение…
- Объединение APK…
- Объединение не удалось!
- Подписывание APK…
- Ошибка подписания!
- Ошибка объединения: %1$s
- Github
- Применить
- Нет недавних файлов
- Запомни этот выбор
+
+ Проводник файлов
+ Корень
+ Внутреннее хранилище
+ Требуется разрешение на доступ к хранилищу
+ Текстовый редактор
+ Проводник файлов
+ Перейти к пути
+ Путь назначения
+ Открыть
+ Неверный путь
+ Новая вкладка
+ Корзина
+ Сохранение…
+ Предупреждение
+ Некоторые файлы в текстовом редакторе не сохранены. Хотите сохранить их сейчас?
+ Игнорировать
+ Сохранить
+ пустая папка
+ " (%s выбрано)"
+ Файлы
+ Новая задача добавлена
+ Имя пакета
+ Название версии
+ Код версии
+ Размер
+ Обзор
+ Установить
+ Закладки
+ Удалить
+ Пусто
+ Задача
+ Поиск
+ Создать
+ Сортировка
+ Выбрать все
+ Параметры
+ Новое содержимое
+ Имя
+ Недопустимое имя файла
+ Файл
+ Не удалось создать файл
+ Файл с похожим именем уже существует
+ Папка
+ Не удалось создать папку
+ Недопустимое имя папки
+ Подтвердить
+ Отклонить
+ Подтверждение удаления
+ Переместить в корзину
+ Создать архив
+ Отмена
+ Добавлено в закладки
+ Открыть в новой вкладке
+ Открыть с помощью
+ Добавить на главный экран
+ Редактировать в текстовом редакторе
+ Сжать
+ выбрано
+ Скопировано в буфер обмена
+ Сортировать по
+ Применить только к этой папке
+ Имя (А-Я)
+ Дата (новее)
+ Размер (меньше)
+ Папки первыми
+ Обратный порядок
+ MIME тип
+ Закрыть
+ Закрыть остальные
+ Переименовать
+ Копировать путь
+ Найти
+ Результаты
+ Поисковый запрос
+ Задачи
+ Не удалось найти приложение для открытия этого типа файлов
+ Не удалось открыть этот файл
+ Управление левым курсором
+ Управление правым курсором
+ Перейти к позиции
+ Строка:Столбец (например, 23:11)
+ Перейти
+ Недопустимая позиция
+ Формат
+ Перенос слов
+ Только чтение
+ Перейти к строке
+ Настройки
+ Использовать ICU выделение
+ Закрепить строку номеров
+ Автопарные символы
+ Удалить пустые строки
+ Удалить табуляции
+ Автоотступы
+ Лупа
+ Недавние файлы
+ Этот файл сейчас открыт
+ Найти
+ Заменить
+ Регулярное выражение
+ Учитывать регистр
+ ЗАМЕН
+ ВСЕ
+ ПРЕД
+ СЛЕД
+ Сохранено
+ Не удалось сохранить
+ Не удалось загрузить файл символов
+ Исходный файл был изменен, хотите перезагрузить его?
+ Перезагрузить
+ Игнорировать изменения
+ Этот файл не был сохранен, хотите сохранить его?
+ Файл не найден
+ Файл больше не существует
+ Переместить
+ Подготовка
+ Удаление исходных файлов…
+ Копировать
+ Удаление
+ Сжатие
+ Настройки
+ Тема
+ Выбрать предпочтение темы
+ Светлая
+ Темная
+ Следовать системе
+ Текстовый редактор
+ Лимит недавних файлов
+ Выберите максимальное количество недавних файлов для отображения
+ Размер списка файлов
+ Маленький
+ Средний
+ Большой
+ Очень большой
+ Выберите подходящий размер для списка файлов
+ Показать подписи нижней панели
+ Общие
+ Лимит поиска файлов
+ Ограничить количество результатов при поиске
+ Количество колонок списка файлов
+ Выберите количество колонок
+ Просмотр PDF
+ Неизвестно
+ Количество страниц
+ Просмотр изображений
+ Медиапроигрыватель
+ Недопустимый медиафайл
+ Неизвестный исполнитель
+ Показать количество содержимого папки
+ Показать скрытые файлы
+ Вы действительно хотите удалить выбранные файлы?
+ Содержимое: %1$d
+ %1$d:%2$d
+ "результат поиска: "
+ папки: %1$d
+ файлы: %1$d
+ Главная
+ Главная
+ Категории
+ Изображения
+ Видео
+ Аудио
+ Документы
+ Архивы
+ Хранилище
+ Задачи не могут быть выполнены здесь
+ Приложения
+ Приложения
+ Приложения
+ Пользователь
+ Система
+ Еще
+ Github
+ Применить
+ Нет недавних файлов
+ Запомнить этот выбор
+ Корень
+ Подсчет файлов…
+ Копирование
+ Файл %1$s уже существует в папке назначения.
+ Скрыть
+ Конфликт
+ Пропустить
+ Задача не готова.
+ Задача не найдена.
+ Применить к последующим конфликтам
+ Эта папка недоступна
+ Включите показ скрытых файлов в настройках
+ Исходные файлы не найдены.
+ Что-то пошло не так: \n%1$s
+ Отсутствует место назначения.
+ "Извлечение "
+ Недопустимая папка назначения.
+ Создать и открыть
+ "Переименование"
+ Массовое переименование
+ Новое имя
+ Текст для поиска
+ Заменить на
+ Регулярное выражение
+ Предпросмотр
+ Расширение
+ Последнее изменение
+ Числовой инкремент
+ Числовой инкремент с нулями
+ Найти и заменить
+ Синтаксис
+ Расширение с точкой
+ Ошибка создания папки корзины
+ Недопустимый zip файл
+ Долгое нажатие для показа меню файла
+ Свойства файла
+ Свойства выделения
+ Загрузка свойств…
+ Анализ файлов…
+ Расположение
+ Тип
+ Изменен
+ Система
+ Владелец
+ Разрешения
+ Анализ
+ Содержимое
+ MD5 контрольная сумма
+ Сводка выделения
+ %1$d элементов
+ Общий размер
+ Общее содержимое
+ Н/Д
+ Вычисление…
+ %1$d файлов, %2$d папок
+ Ошибка вычисления
+ Добавить в закладки
+ Операция с файлом
+ Внешний вид
+ Отключить потягивание для обновления
+ Пропустить главную при закрытии вкладок
+ Список файлов
+ Поведение
+ Неограниченно
+ Очень маленький
+ Недопустимые задачи
+ Ожидающие задачи
+ Неудачные задачи
+ Приостановленные задачи
+ Эта задача сейчас выполняется
+ Выполняющиеся задачи
+ Нет доступных задач
+ Нет доступных закладок
+ Остановить
+ Задача не выполнена
+ Задача приостановлена
+ Задача завершена
+ Остановка…
+ Требуется доступ к хранилищу
+ Для управления файлами нам нужен доступ к хранилищу устройства. Это позволит сохранять, организовывать и получать доступ к содержимому.
+ Сохранить файлы
+ Сохранить и организовать документы
+ Доступ к папкам
+ Просматривать и управлять структурой файлов
+ Предоставить доступ
+ Пропустить сейчас
+ Путь
+ Недопустимый PDF
+ Не удалось загрузить PDF
+ Страница %1$d
+ Загрузка PDF…
+ Ошибка
+ Назад
+ Информация PDF
+ Просмотр текста
+ Редактировать с помощью…
+ Не найдено приложение для редактирования изображения
+ Загрузка изображения…
+ Не удалось загрузить изображение
+ Проверьте существование файла и попробуйте снова
+ Повторить
+ Размеры
+ Информация недоступна
+ Аудиоплеер
+ Неизвестное название
+ Неизвестный альбом
+ Громкость
+ Эквалайзер
+ Сброс
+ Готово
+ Логи
+ Очистить логи
+ Вы уверены, что хотите очистить все логи? Это действие нельзя отменить.
+ Очистить
+ Логи недоступны
+ Ошибки не зарегистрированы
+ Предупреждения не зарегистрированы
+ Информационные логи недоступны
+ Отлично! Ошибки не обнаружены
+ Предупреждения не зарегистрированы
+ Информационные логи не записаны
+ Очистка логов…
+ Неподдерживаемая комбинация источника/назначения
+ Неизвестная ошибка
+ Не удалось скопировать файлы в zip
+ Не удалось извлечь файлы из zip
+ Не удалось скопировать записи zip
+ Конвертировать в APK
+ Недопустимый файл пакета APK
+ Слияние
+ Подписание
+ Файл пакета APK успешно конвертирован в APK
+ Не удалось конвертировать пакет APK в APK
+ Не удалось подписать объединенный APK файл
+ Автоподпись объединенных файлов пакета APK
+ Автоматически подписывать объединенные файлы пакета APK тестовым ключом
+ Настроить домашнюю вкладку
+ Настроить Домашнюю Вкладку
+ Недавно измененные файлы
+ Категории быстрого доступа
+ Устройства хранения и местоположения
+ Файлы и папки в закладках
+ Удаленные файлы
+ Быстрая навигация по путям
+ Уведомление о выполнении задач
+ Задачи выполняются…
+ Задача не может быть продолжена из текущего состояния: %1$s
+ Проверка задачи не удалась
+ Задача не находится в состоянии ожидания
+ Требуется действие
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
deleted file mode 100644
index 04b7479c..00000000
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ /dev/null
@@ -1,220 +0,0 @@
-
-
- File Explorer
- 根目录
- 内部存储
- 需要授予存储空间权限
- 文本编辑器
- 插件
- 文件浏览器
- 跳转至路径
- 目标路径
- 打开
- 路径无效
- 新标签页
- 应用程序文件
- 回收站
- 正在保存…
- 警告
- 文本编辑器修改的内容未保存. 需要现在保存吗?
- 忽略
- 保存
- 空文件夹
- " (已选中 %s 个项目)"
- 文件
- 已添加至任务队列
- 应用包名
- 版本
- 版本号
- 大小
- 查看内容
- 安装
- 书签
- 删除
- 什么都没有¯\\_(ツ)_/¯
- 任务队列
- 搜索
- 创建
- 分类
- 全选
- 选项
- 新建
- 名称
- 名称无效
- 文件
- 文件创建失败
- 同名文件已存在
- 文件夹
- 文件夹创建失败
- 文件夹命名无效
- 确认
- 取消
- 确认删除
- 移动至回收站
- 创建压缩包
- 无法创建文件
- 取消
- 已添加至书签
- 在新标签页中打开
- 打开方式…
- 添加桌面快捷方式
- 使用文本编辑器打开
- 压缩
- 解压
- 详细信息
- 属性
- 路径
- URI
- 修改日期
- 父目录
- 选中的项目
- 包含子目录内的项目
- 共计: %1$d 项(文件: %2$d 个 | 文件夹: %3$d个)
- 共计: %1$d 项(文件: %2$d 个 | 文件夹: %3$d个)
- 已复制到剪贴板
- 排序方式
- 只针对此文件夹设置
- 名称(A-Z)
- 日期(从新到旧)
- 体积(从小到大)
- 目录排前
- 反转排序顺序
- MIME
- 关闭
- 关闭右侧全部标签
- 全部关闭
- 重命名
- 复制路径
- 定位至此文件
- 搜索结果
- 键入要搜索的内容
- 任务清单
- 无效任务
- 找不到可用于打开此类文件的程序
- 打开此文件失败
- 光标左移
- 光标右移
- 跳转到指定位置
- 格式 行:列 (例如 23:11)
- 出发
- 位置无效
- 格式
- 自动换行
- 只读模式
- 跳转位置
- 首选项
- 使用国际Unicode组件
- 置顶行数栏
- 自动配对标点
- 删除空行
- 删除制表符
- 自动缩进
- 移动光标启动放大镜
- 最近文件
- 此文件目前处于被打开状态
- 查找
- 替换
- 正则表达式
- 区分大小写
- 替换
- 全部替换
- 上一个
- 下一个
- 已保存
- 保存失败
- 未能创建临时文件
- 未能加载符号文件
- 检测到源文件已经改变,重载编辑器?
- 重载
- 忽略
- 文件为未保存,你想要保存它吗?
- 文件未找到
- 文件已不存在
- 移动
- 准备中…
- 开始移动…
- 进度: %1$d/%2$d
- 正在移动: %1$s\n移动至: %2$s
- 正在删除源文件
- 已完成
- 复制
- 正在复制: %1$s\n复制至: %2$s
- 进度: %1$d
- 正在删除: %1$s
- 解压中: %1$s
- 压缩中: $%1$s
- 首选项
- 显示和外观
- 主题
- 选择偏好的主题
- 亮色
- 暗色
- 跟随系统
- 文本编辑器
- “最近文件”显示限制
- 设置“最近文件”的文件数量
- 文件图标大小
- 小
- 适中
- 大
- 很大
- 选择文件浏览界面的图标大小
- 显示底部导航栏的标签
- 通用
- 文件搜索结果数量
- 在搜索文件时展示结果的上限
- 文件列数
- 选择显示文件的列数
- PDF阅读器
- 未知
- 页码: %1$d
- 图片查看器
- 媒体查看器
- 媒体文件无效
- 未知作者
- 搜索完成
- 显示文件夹内容计数器
- 显示隐藏文件
- 您确定要删除选定的文件吗?
- 大小:%1$d
- %1$d:%2$d
- "搜索结果:"
- 文件夹:%1$d
- 文件:%1$d
- 首页
- 首页
- 分类
- 图片
- 视频
- 音频
- 文档
- 压缩包
- 存储
- 无法在此运行任务
- 应用
- 应用
- 应用
- 用户
- 系统
- 更多
- 转换为 APK
- 自动签名 APK
- 在 APK 操作后签名 APK
- 失败
- 合并成功!
- 合并完成
- 正在初始化合并…
- 正在提取文件…
- 正在合并模块…
- 正在完成合并…
- 正在合并…
- 正在合并 APK…
- 合并未成功!
- 正在签名 APK…
- 签名失败!
- 合并失败: %1$s
- Github
- 应用
- 没有最近文件
- 记住这个选择
-
\ No newline at end of file
diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml
deleted file mode 100644
index 6aebde36..00000000
--- a/app/src/main/res/values-zh-rTW/strings.xml
+++ /dev/null
@@ -1,219 +0,0 @@
-
- File Explorer
- 根目錄
- 內部儲存空間
- 需要儲存空間存取權限
- 文字編輯器
- 外掛程式
- 檔案瀏覽器
- 跳轉至路徑
- 目標路徑
- 開啟
- 無效的路徑
- 新分頁
- 應用程式檔案
- 回收桶
- 正在儲存…
- 警告
- 文字編輯器中的一些檔案尚未儲存。您是否想要現在儲存?
- 忽略
- 儲存
- 空資料夾
- " (%s 個已選)"
- 檔案
- 新任務已被列入
- 套件名稱
- 版本名稱
- 版本代號
- 大小
- 瀏覽封裝內容
- 安裝
- 書籤
- 刪除
- 空無一物
- 任務
- 搜尋
- 建立
- 排序
- 全選
- 選項
- 建立新項目
- 名稱
- 無效的檔案名稱
- 檔案
- 檔案建立失敗
- 相同的檔案名稱已經存在
- 資料夾
- 資料夾建立失敗
- 無效的資料夾名稱
- 確認
- 取消
- 確認刪除
- 移動到回收筒
- 建立壓縮檔
- 無法建立檔案
- 取消
- 已新增至書籤
- 開啟於新分頁
- 開啟方式
- 新增至主畫面
- 使用文字編輯器進行編輯
- 壓縮
- 解壓縮
- 詳細資料
- 屬性
- 路徑
- URI
- 修改日期
- 父目錄
- 已選擇
- 內容
- 總共: %1$d (檔案: %2$d | 資料夾: %3$d)
- 總共: %1$d (檔案: %2$d | 資料夾: %3$d)
- 複製至剪貼簿
- 排序依據
- 僅應用於此資料夾
- 名稱 (A-Z)
- 日期 (新的優先)
- 大小 (小的優先)
- 資料夾優先
- 反向排序
- MIME 型式
- 關閉分頁
- 關閉右方分頁
- 關閉全部分頁
- 重新命名
- 複製路徑
- 前往所在位置
- 搜尋結果
- 搜尋字詞
- 任務清單
- 無效的任務
- 找不到任何可以開啟此檔案類型的應用程式
- 開啟此檔案失敗
- 游標左移
- 游標右移
- 跳至指定位置
- 行:欄 (例如 23:11)
- 前往
- 無效的位置
- 格式
- 自動換行
- 唯讀
- 跳至指定行
- 偏好設定
- 使用國際統一碼部件
- 固定顯示行數欄
- 自動符號配對
- 刪除空行
- 刪除跳欄(Tab)字元
- 自動縮行
- 放大鏡
- 最近的檔案
- 此檔案正在使用中
- 尋找
- 取代
- 正規表示法
- 區分大小寫
- 取代
- 全部取代
- 上一個
- 下一個
- 已儲存
- 儲存失敗
- 建立暫存檔案失敗
- 載入符號檔案失敗
- 來源檔已變更,您是否想要重新載入內容?
- 重新載入
- 忽略變更
- 此檔案尚未被儲存,您是否想要儲存它?
- 找不到檔案
- 檔案已不存在
- 移動
- 正在準備…
- 開始移動任務…
- 進度: %1$d/%2$d
- 正在移動: %1$s\n目標位置: %2$s
- 刪除來源檔案…
- 已完成
- 複製
- 正在複製: %1$s\n目標位置: %2$s
- 進度: %1$d
- 正在刪除: %1$s
- 正在解壓縮: %1$s
- 正在壓縮: $%1$s
- 偏好設定
- 顯示
- 主題
- 選擇偏好主題
- 淺色
- 深色
- 遵循系統
- 文字編輯器
- 最近的檔案限制
- 選擇最近的檔案要顯示的最大數量
- 檔案清單大小
- 小
- 中
- 大
- 超大
- 為檔案清單選擇合適的顯示大小
- 顯示底部選項列標籤
- 一般
- 檔案搜尋限制
- 搜尋時顯示結果的數量限制
- 檔案清單欄數
- 選擇欄位的數量
- PDF 檢視器
- 未知
- 頁碼: %1$d
- 圖片檢視器
- 媒體檢視器
- 無效的媒體檔案
- 未知作者
- 搜尋完成
- 顯示資料夾的子項目數量
- 顯示隱藏的檔案
- 您是否確定要刪除選取的檔案?
- 大小: %1$d
- %1$d:%2$d
- "搜尋結果: "
- 資料夾: %1$d
- 檔案: %1$d
- 首頁
- 首頁
- 分類
- 圖片
- 影片
- 音訊
- 文件
- 壓縮檔
- 儲存空間
- 無法在此執行任務
- 應用程式
- 應用程式
- 應用程式
- 使用者
- 系統
- 更多
- 轉換為 APK
- 自動簽署 APK
- 在 APK 操作後簽署 APK
- 失敗
- 合併成功!
- 合併完成
- 正在初始化合併…
- 正在匯出檔案…
- 正在合併模組…
- 正在完成合併…
- 正在合併…
- 正在合併 APK…
- 合併不成功!
- 正在簽署 APK…
- 簽署失敗!
- 合併失敗: %1$s
- Github
- 套用
- 沒有最近使用的檔案
- 記住這個選擇
-
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
new file mode 100644
index 00000000..7601ee9e
--- /dev/null
+++ b/app/src/main/res/values-zh/strings.xml
@@ -0,0 +1,328 @@
+
+ 文件管理器
+ 根目录
+ 内部存储
+ 需要存储访问权限
+ 文本编辑器
+ 文件管理器
+ 跳转到路径
+ 目标路径
+ 打开
+ 无效路径
+ 新标签页
+ 回收站
+ 保存中…
+ 警告
+ 文本编辑器中的一些文件未保存。您想现在保存它们吗?
+ 忽略
+ 保存
+ 空文件夹
+ " (已选择 %s)"
+ 文件
+ 已添加新任务
+ 包名
+ 版本名称
+ 版本代码
+ 大小
+ 浏览
+ 安装
+ 书签
+ 删除
+ 空
+ 任务
+ 搜索
+ 创建
+ 排序
+ 全选
+ 选项
+ 新内容
+ 名称
+ 无效文件名
+ 文件
+ 创建文件失败
+ 已存在相似名称的文件
+ 文件夹
+ 创建文件夹失败
+ 无效文件夹名
+ 确认
+ 取消
+ 删除确认
+ 移动到回收站
+ 创建压缩包
+ 取消
+ 已添加到书签
+ 在新标签页中打开
+ 打开方式
+ 添加到主屏幕
+ 用文本编辑器编辑
+ 压缩
+ 已选择
+ 已复制到剪贴板
+ 排序方式
+ 仅应用于此文件夹
+ 名称 (A-Z)
+ 日期 (较新)
+ 大小 (较小)
+ 文件夹优先
+ 倒序
+ MIME类型
+ 关闭
+ 关闭其他
+ 重命名
+ 复制路径
+ 定位
+ 结果
+ 搜索查询
+ 任务
+ 找不到可以打开此类型文件的应用
+ 打开此文件失败
+ 控制左光标
+ 控制右光标
+ 跳转到位置
+ 行:列 (例如 23:11)
+ 前往
+ 无效位置
+ 格式
+ 自动换行
+ 只读
+ 跳转到行
+ 首选项
+ 使用ICU选择
+ 固定行号
+ 自动符号配对
+ 删除空行
+ 删除制表符
+ 自动缩进
+ 放大镜
+ 最近文件
+ 此文件当前已打开
+ 查找
+ 替换
+ 正则表达式
+ 区分大小写
+ 替换
+ 全部
+ 上一个
+ 下一个
+ 已保存
+ 保存失败
+ 加载符号文件失败
+ 源文件已更改,您想重新加载吗?
+ 重新加载
+ 忽略更改
+ 此文件尚未保存,您想保存吗?
+ 文件未找到
+ 文件不再存在
+ 移动
+ 准备中
+ 删除源文件…
+ 复制
+ 删除中
+ 压缩中
+ 首选项
+ 主题
+ 选择主题偏好
+ 浅色
+ 深色
+ 跟随系统
+ 文本编辑器
+ 最近文件限制
+ 选择显示的最大最近文件数
+ 文件列表大小
+ 小
+ 中
+ 大
+ 超大
+ 为文件列表选择合适的大小
+ 显示底部栏标签
+ 常规
+ 文件搜索限制
+ 限制搜索时显示的结果数量
+ 文件列表列数
+ 选择列数
+ PDF查看器
+ 未知
+ 页数
+ 图片查看器
+ 媒体查看器
+ 无效媒体文件
+ 未知艺术家
+ 显示文件夹内容计数
+ 显示隐藏文件
+ 您真的要删除选中的文件吗?
+ 内容: %1$d
+ %1$d:%2$d
+ "搜索结果: "
+ 文件夹: %1$d
+ 文件: %1$d
+ 主页
+ 主页
+ 分类
+ 图片
+ 视频
+ 音频
+ 文档
+ 压缩包
+ 存储
+ 无法在此处运行任务
+ 应用
+ 应用
+ 应用
+ 用户
+ 系统
+ 更多
+ Github
+ 应用
+ 没有最近文件
+ 记住此选择
+ 根目录
+ 计算文件中…
+ 复制中
+ 文件 %1$s 在目标文件夹中已存在。
+ 隐藏
+ 冲突
+ 跳过
+ 任务未准备好。
+ 任务未找到。
+ 应用于后续冲突
+ 此文件夹无法访问
+ 在设置中显示隐藏文件以查看所有文件
+ 未找到源文件。
+ 出现错误: \n%1$s
+ 目标缺失。
+ "解压中 "
+ 无效目标目录。
+ 创建并打开
+ "重命名中"
+ 批量重命名
+ 新名称
+ 要查找的文本
+ 替换为
+ 正则表达式
+ 预览
+ 扩展名
+ 最后修改
+ 数字递增
+ 零填充数字递增
+ 查找和替换
+ 语法
+ 带点的扩展名
+ 创建回收站文件夹出错
+ 无效的zip文件
+ 长按显示文件选项菜单
+ 文件属性
+ 选择属性
+ 加载属性中…
+ 分析文件中…
+ 位置
+ 类型
+ 修改时间
+ 系统
+ 所有者
+ 权限
+ 分析
+ 内容
+ MD5校验和
+ 选择摘要
+ %1$d 项
+ 总大小
+ 总内容
+ 不可用
+ 计算中…
+ %1$d 个文件,%2$d 个文件夹
+ 计算错误
+ 添加到书签
+ 文件操作
+ 外观
+ 禁用下拉刷新
+ 关闭标签页时跳过主页
+ 文件列表
+ 行为
+ 无限制
+ 超小
+ 无效任务
+ 待处理任务
+ 失败任务
+ 暂停任务
+ 此任务正在运行
+ 运行中任务
+ 没有可用任务
+ 没有可用书签
+ 停止
+ 任务失败
+ 任务已暂停
+ 任务完成
+ 停止中…
+ 需要存储访问权限
+ 为了管理您的文件,我们需要访问您的设备存储。这使您能够无缝保存、组织和访问您的内容。
+ 保存文件
+ 保存并整理您的文档
+ 访问文件夹
+ 浏览和管理您的文件结构
+ 授予访问权限
+ 暂时跳过
+ 路径
+ 无效PDF
+ 加载PDF失败
+ 第 %1$d 页
+ 加载PDF中…
+ 错误
+ 返回
+ PDF信息
+ 文本查看器
+ 编辑方式…
+ 找不到编辑此图片的应用
+ 加载图片中…
+ 加载图片失败
+ 检查文件是否存在并重试
+ 重试
+ 尺寸
+ 没有可用信息
+ 音频播放器
+ 未知标题
+ 未知专辑
+ 音量
+ 均衡器
+ 重置
+ 完成
+ 日志
+ 清除日志
+ 您确定要清除所有日志吗?此操作无法撤销。
+ 清除
+ 没有可用日志
+ 没有错误日志
+ 没有警告日志
+ 没有信息日志
+ 太好了!没有遇到错误
+ 没有警告日志
+ 没有记录信息日志
+ 清除日志中…
+ 不支持的源/目标组合
+ 未知错误
+ 复制文件到zip失败
+ 从zip解压文件失败
+ 复制zip条目失败
+ 转换为APK
+ 无效的APK包文件
+ 合并中
+ 签名中
+ APK包文件成功转换为APK
+ APK包转换为APK失败
+ 合并APK文件签名失败
+ 自动签名合并的APK包文件
+ 使用测试密钥自动签名合并的APK包文件
+ 自定义主页标签
+ 自定义主页标签
+ 最近修改的文件
+ 快速访问分类
+ 存储设备和位置
+ 书签文件和文件夹
+ 已删除文件
+ 快速路径导航
+ 任务运行通知
+ 任务正在运行…
+ 任务无法从当前状态继续:%1$s
+ 任务验证失败
+ 任务不在待处理状态
+ 需要操作
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
deleted file mode 100644
index f8c6127d..00000000
--- a/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
- #FF000000
- #FFFFFFFF
-
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1ab9f132..ddca7133 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -4,14 +4,12 @@
Internal Storage
Storage access permission is required
TextEditorActivity
- Plugins
File Explorer
Jump to path
Destination path
Open
Invalid path
New Tab
- App files
Recycle bin
Saving…
Warning
@@ -37,7 +35,7 @@
Sort
Select All
Options
- Create New
+ New Content
Name
Invalid file name
File
@@ -51,7 +49,6 @@
Delete Confirmation
Move to Recycle Bin
Create Archive
- Unable to create file
Cancel
Added to bookmarks
Open in new tab
@@ -59,17 +56,7 @@
Add to home screen
Edit with text editor
Compress
- Decompress
- Details
- Properties
- path
- uri
- modification date
- parent
selected
- content
- total: %1$d (files: %2$d | folders: %3$d)
- total: %1$d (files: %2$d | folders: %3$d)
Copied to clipboard
Sort by
Apply to this folder only
@@ -81,14 +68,12 @@
MIME Type
Close
Close others
- Close all
Rename
Copy path
Locate
Results
Search Query
Tasks
- Invalid task
Could not find any app that can open this type of files
Failed to open this file
Controlling left cursor
@@ -121,7 +106,6 @@
NEXT
Saved
Failed to save
- Failed to create temporary file
Failed to load symbols file
The source file has been changed, do you want to reload it?
Reload
@@ -130,20 +114,12 @@
File not found
File no longer exists
Move
- Preparing…
- Start moving task…
- Progress: %1$d/%2$d
- Moving: %1$s\nDestination: %2$s
+ Preparing
Deleting source files…
- Done
Copy
- Copying: %1$s\nDestination: %2$s
- Progress: %1$d
- Deleting: %1$s
- Decompressing: %1$s
- Compressing: $%1$s
+ Deleting
+ Compressing
Preferences
- Display
Theme
Select theme preference
Light
@@ -156,7 +132,7 @@
Small
Medium
Large
- Extra Large
+ Extra large
Select a suitable size for the file list
Show bottom options bar labels
General
@@ -166,16 +142,15 @@
Choose number of columns
PDF Viewer
Unknown
- Page Count: %1$d
+ Page Count
Image Viewer
Media Viewer
Invalid media file
Unknown Artist
- Search finished
Show folder\'s content count
Show hidden files
Do you really want to delete the selected files?
- Size: %1$d
+ Content: %1$d
%1$d:%2$d
"search result: "
folders: %1$d
@@ -196,24 +171,159 @@
User
System
More
- Convert to APK
- Auto sign APK
- Sign APK after APK Operations
- Failed
- Merge Successful!
- Merge Completed
- Initializing merge…
- Extracting files…
- Merging modules…
- Finalizing merge…
- Merging…
- Merging APKs…
- Merge was not successful!
- Signing APK…
- Signing failed!
- Merge failed: %1$s
Github
Apply
No Recent Files
Remember this choice
+ Root
+ Counting files…
+ Copying
+ File %1$s already exists in destination folder.
+ Hide
+ Conflict
+ Skip
+ Task is not ready.
+ Task not found.
+ Apply to subsequent conflicts
+ This folder is inaccessible
+ Show hidden files in settings to see all files
+ No source files were found.
+ Something went wrong: \n%1$s
+ The destination was missing.
+ "Extracting "
+ Invalid destination directory.
+ Create and open
+ "Renaming"
+ Batch Rename
+ New Name
+ Text to find
+ Replace with
+ Regex
+ Preview
+ Extension
+ Last modified
+ Number increment
+ Zero-padded number increment
+ Find and replace
+ file name without extension (e.g., \"document\" from \"document.txt\")\n\n{s} -> file extension (e.g., \"txt\" from \"document.txt\")\n\n{e} -> file extension with a dot (e.g., \".txt\" from \"document.txt\")\n\n{t} -> file\'s last modified time (formatted as \"yyyyMMdd_HHmmss\")\n\n{n} -> number increment (e.g., {0} -> 0, 1, 2…; {1} -> 1, 2, 3…)\n\n{zn} -> zero-padded number increment (e.g., {z0} -> 0, 1…; {zz0} -> 00, 01…; {zzz1} -> 001, 002…)]]>
+ Syntax
+ Extension with a dot
+ Error creating recycle bin folder
+ Invalid zip file
+ Long click to show file\'s options menu
+ File Properties
+ Selection Properties
+ Loading Properties…
+ Analyzing files…
+ Location
+ Type
+ Modified
+ System
+ Owner
+ Permissions
+ Analysis
+ Contents
+ MD5 Checksum
+ Selection Summary
+ %1$d items
+ Total Size
+ Total Contents
+ N/A
+ Calculating…
+ %1$d files, %2$d folders
+ Error calculating
+ Add to bookmarks
+ File Operation
+ Appearance
+ Disable pull down to refresh
+ Skip home screen when closing tabs
+ File List
+ Behavior
+ Unlimited
+ Extra small
+ Invalid tasks
+ Pending tasks
+ Failed tasks
+ Paused tasks
+ This task is currently running
+ Running tasks
+ No tasks available
+ No bookmarks available
+ Stop
+ Task failed
+ Task has been paused
+ Task completed
+ Stopping…
+ Storage Access Required
+ To manage your files, we need access to your device storage. This allows you to save, organize, and access your content seamlessly.
+ Save Files
+ Save and organize your documents
+ Access Folders
+ Browse and manage your file structure
+ Grant Access
+ Skip for now
+ Path
+ Invalid PDF
+ Failed to load PDF
+ Page %1$d
+ Loading PDF…
+ Error
+ Go Back
+ PDF Info
+ Text Viewer
+ Edit with…
+ No app found to edit this image
+ Loading image…
+ Failed to load image
+ Check if the file exists and try again
+ Retry
+ Dimensions
+ No information available
+ Audio Player
+ Unknown Title
+ Unknown Album
+ Volume
+ Equalizer
+ Reset
+ Done
+ Logs
+ Clear logs
+ Are you sure you want to clear all logs? This action cannot be undone.
+ Clear
+ No logs available
+ No errors logged
+ No warnings logged
+ No info logs available
+ Great! No errors have been encountered
+ No warnings have been logged
+ No information logs have been recorded
+ Clearing logs…
+ Unsupported source/destination combination
+ Unknown error
+ Failed to copy files to zip
+ Failed to extract files from zip
+ Failed to copy zip entries
+ Convert to APK
+ Invalid APK bundle file
+ Merging
+ Signing
+ APK bundle file successfully converted to APK
+ Failed to convert APK bundle to APK
+ Failed to sign the merged APK file
+ Auto sign merged APK bundle files
+ Automatically sign merged APK bundle files with a test key
+ Customize home tab
+ Customize Home Tab
+ Recently modified files
+ Quick access categories
+ Storage devices and locations
+ Bookmarked files and folders
+ Deleted files
+ Quick path navigation
+ Task Running Notification
+ Tasks are running...
+ Task cannot be continued from current state: %1$s
+ Task validation failed
+ Task is not in pending state
+ Action required
\ No newline at end of file
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index 9ee9997b..6cebe057 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -5,7 +5,7 @@
-->
-
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 00000000..6d1696f0
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e07de1ff..b6092d6f 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,42 +1,51 @@
[versions]
+accompanistSystemuicontroller = "0.36.0"
agp = "8.6.1"
+apksig = "8.11.0"
+commonsNet = "3.11.1"
desugar_jdk_libs = "2.1.5"
-grid = "2.2.1"
-kotlin = "2.0.0"
+grid = "2.3.2"
+kotlin = "2.2.0"
coreKtx = "1.16.0"
-appcompat = "1.7.0"
+appcompat = "1.7.1"
lazycolumnscrollbar = "2.2.0"
material = "1.12.0"
-androidxComposeBom = "2025.05.01"
+androidxComposeBom = "2025.06.01"
media3Exoplayer = "1.7.1"
-soraEditor = "0.23.5"
+paletteKtx = "1.0.0"
+soraEditor = "0.23.6"
gson = "2.13.1"
dataStore = "1.1.7"
-storage = "2.0.0"
+storage = "2.1.0"
cascade_compose = "2.3.0"
compose_swipebox = "1.4.0"
-lifecycle_runtime_compose_android = "2.9.0"
+lifecycle_runtime_compose_android = "2.9.1"
activity_compose = "1.10.1"
-lifecycle_runtime_ktx = "2.9.0"
+lifecycle_runtime_ktx = "2.9.1"
coilCompose = "3.2.0"
-zoomable = "2.7.0"
+zip4j = "2.11.5"
+zoomable = "2.8.1"
junit = "1.2.1"
espressoCore = "3.6.1"
uiautomator = "2.3.0"
benchmarkMacroJunit4 = "1.3.4"
baselineprofile = "1.3.4"
profileinstaller = "1.4.1"
-reorderable = "2.4.3"
-apksig = "8.10.0"
+reorderable = "2.5.1"
+uiToolingPreviewAndroid = "1.8.3"
[libraries]
+accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistSystemuicontroller" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3Exoplayer" }
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3Exoplayer" }
+androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" }
+apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coilCompose" }
coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coilCompose" }
coil-video = { module = "io.coil-kt.coil3:coil-video", version.ref = "coilCompose" }
+commons-net = { module = "commons-net:commons-net", version.ref = "commonsNet" }
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
grid = { module = "com.cheonjaeung.compose.grid:grid", version.ref = "grid" }
lazycolumnscrollbar = { module = "com.github.nanihadesuka:LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" }
@@ -57,6 +66,7 @@ cascade-compose = { group = "me.saket.cascade", name = "cascade-compose", versio
compose-swipebox = { group = "io.github.kevinnzou", name = "compose-swipebox", version.ref = "compose_swipebox" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle_runtime_ktx" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose-android", version.ref = "coilCompose"}
+zip4j = { module = "net.lingala.zip4j:zip4j", version.ref = "zip4j" }
zoomable = { module = "net.engawapg.lib:zoomable", version.ref = "zoomable" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -64,7 +74,7 @@ androidx-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomato
androidx-benchmark-macro-junit4 = { group = "androidx.benchmark", name = "benchmark-macro-junit4", version.ref = "benchmarkMacroJunit4" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "profileinstaller" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
-apksig = { module = "com.android.tools.build:apksig", version.ref = "apksig"}
+androidx-ui-tooling-preview-android = { group = "androidx.compose.ui", name = "ui-tooling-preview-android", version.ref = "uiToolingPreviewAndroid" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }