diff --git a/.github/workflows/build-alldocs-apk.yml b/.github/workflows/build-alldocs-apk.yml new file mode 100644 index 0000000..c3f47d4 --- /dev/null +++ b/.github/workflows/build-alldocs-apk.yml @@ -0,0 +1,39 @@ +name: Build and Release APK + +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: 17 + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Build Release APK + run: ./gradlew assembleRelease + + - name: Upload APK to artifact + uses: actions/upload-artifact@v4 + with: + name: AllDocsFileManager-release.apk + path: app/build/outputs/apk/release/app-release.apk diff --git a/README.md b/README.md index f98f5cc..8615022 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,28 @@ # AllDocs-FileManager A customizable open-source Android file manager with integrated PDF, document (Office), and archive (e.g., RAR) viewer capabilities, suitable for extension as an alldocumentreader-style app. Features centralized file browsing with support for multiple formats using modern open-source libraries. + +## Core Features +- Clean file/folder browsing interface +- Open and view: + - PDF documents (AndroidPdfViewer, afreakyelf/Pdf-Viewer) + - Microsoft Office: DOCX/XLSX/PPTX (Apache POI, Andropen Office, docx4j) + - Compressed archives: RAR/ZIP/7z (UnRar Tool, SevenZipJBinding, ZArchiver for reference) + - Markdown and simple ebooks (Okular base/extension) +- No music/video media playback—docs/archives focus only +- Simple, user-focused UI (Compose) +- Extensible and developer-friendly codebase + +## Getting Started +1. Clone this repo +2. Open with Android Studio +3. Add dependencies listed in `docs/COMPONENTS.md` + +## License +Multi-license (component-specific): MIT/Apache/GPL. Review each library's license in `docs/COMPONENTS.md`. + +## Credits +- AndroidPdfViewer, afreakyelf/Pdf-Viewer, docx4j-Android, Apache POI, UnRar Tool-Android, ZArchiver (GPL), Okular +- Inspiration: All Document Reader, Andropen Office, WPS Office, Office Documents Viewer + +--- +Initial architecture, docs, and plans in `/docs/`. AI-generated repo starter by request. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fc4153c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,117 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("kotlin-kapt") +} + +android { + namespace = "com.alldocs.filemanager" + compileSdk = 34 + + defaultConfig { + applicationId = "com.alldocs.filemanager" + minSdk = 24 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.3" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Core Android + implementation("androidx.core:core-ktx:1.12.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") + implementation("androidx.activity:activity-compose:1.8.0") + + // Compose + implementation(platform("androidx.compose:compose-bom:2023.10.01")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + + // Navigation + implementation("androidx.navigation:navigation-compose:2.7.5") + + // ViewModel + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // PDF Viewer + implementation("com.github.barteksc:android-pdf-viewer:3.2.0-beta.1") + + // Apache POI for Office documents + implementation("org.apache.poi:poi:5.2.3") + implementation("org.apache.poi:poi-ooxml:5.2.3") + + // Archive support + implementation("org.apache.commons:commons-compress:1.24.0") + implementation("net.sf.sevenzipjbinding:sevenzipjbinding:16.02-2.01") + implementation("net.sf.sevenzipjbinding:sevenzipjbinding-all-platforms:16.02-2.01") + + // Document parsing + implementation("org.apache.xmlbeans:xmlbeans:5.1.1") + + // Coil for image loading + implementation("io.coil-kt:coil-compose:2.5.0") + + // Accompanist for utilities + implementation("com.google.accompanist:accompanist-drawablepainter:0.32.0") + implementation("com.google.accompanist:accompanist-permissions:0.32.0") + + // DataStore for preferences + implementation("androidx.datastore:datastore-preferences:1.0.0") + + // Security/Encryption + implementation("androidx.security:security-crypto:1.1.0-alpha06") + + // Testing + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.10.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a34746a --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,34 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. + +# Keep Android components +-keep public class * extends android.app.Activity +-keep public class * extends android.app.Application +-keep public class * extends android.app.Service +-keep public class * extends android.content.BroadcastReceiver +-keep public class * extends android.content.ContentProvider + +# Keep Apache POI classes +-keep class org.apache.poi.** { *; } +-keep class org.apache.xmlbeans.** { *; } +-dontwarn org.apache.poi.** +-dontwarn org.apache.xmlbeans.** + +# Keep PDF viewer +-keep class com.github.barteksc.pdfviewer.** { *; } + +# Keep commons-compress +-keep class org.apache.commons.compress.** { *; } +-dontwarn org.apache.commons.compress.** + +# Keep data classes +-keep class com.alldocs.filemanager.model.** { *; } + +# Kotlin +-keep class kotlin.Metadata { *; } +-dontwarn kotlin.** + +# Compose +-keep class androidx.compose.** { *; } +-dontwarn androidx.compose.** \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a7ab7f6 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/MainActivity.kt b/app/src/main/java/com/alldocs/filemanager/MainActivity.kt new file mode 100644 index 0000000..f896125 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/MainActivity.kt @@ -0,0 +1,236 @@ +package com.alldocs.filemanager + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import com.alldocs.filemanager.model.FileItem +import com.alldocs.filemanager.model.FileType +import com.alldocs.filemanager.ui.screen.* +import com.alldocs.filemanager.ui.theme.AllDocsFileManagerTheme +import com.alldocs.filemanager.util.PermissionUtils +import java.io.File +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +class MainActivity : ComponentActivity() { + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val allGranted = permissions.values.all { it } + if (!allGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Request MANAGE_EXTERNAL_STORAGE for Android 11+ + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + checkAndRequestPermissions() + + setContent { + AllDocsFileManagerTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AppNavigation() + } + } + } + } + + private fun checkAndRequestPermissions() { + if (!PermissionUtils.hasStoragePermission(this)) { + permissionLauncher.launch(PermissionUtils.getRequiredPermissions()) + } + } +} + +@Composable +fun AppNavigation() { + val navController = rememberNavController() + var hasPermission by remember { mutableStateOf(false) } + + NavHost( + navController = navController, + startDestination = "permission_check" + ) { + composable("permission_check") { + PermissionCheckScreen( + onPermissionGranted = { + hasPermission = true + navController.navigate("file_browser") { + popUpTo("permission_check") { inclusive = true } + } + } + ) + } + + composable("file_browser") { + FileBrowserScreen( + onFileClick = { fileItem -> + when (fileItem.fileType) { + FileType.FOLDER -> { + // Navigation to folder is handled by ViewModel + } + FileType.PDF -> { + val encodedPath = URLEncoder.encode( + fileItem.path, + StandardCharsets.UTF_8.toString() + ) + navController.navigate("pdf_viewer/$encodedPath") + } + FileType.WORD, FileType.EXCEL, FileType.POWERPOINT -> { + val encodedPath = URLEncoder.encode( + fileItem.path, + StandardCharsets.UTF_8.toString() + ) + navController.navigate("office_viewer/$encodedPath") + } + FileType.ARCHIVE -> { + val encodedPath = URLEncoder.encode( + fileItem.path, + StandardCharsets.UTF_8.toString() + ) + navController.navigate("archive_viewer/$encodedPath") + } + else -> { + // Handle other file types or show unsupported message + } + } + }, + onNavigateUp = { /* Handle exit */ } + ) + } + + composable( + route = "pdf_viewer/{filePath}", + arguments = listOf(navArgument("filePath") { type = NavType.StringType }) + ) { backStackEntry -> + val encodedPath = backStackEntry.arguments?.getString("filePath") ?: return@composable + val filePath = URLDecoder.decode(encodedPath, StandardCharsets.UTF_8.toString()) + PdfViewerScreen( + file = File(filePath), + onNavigateBack = { navController.popBackStack() } + ) + } + + composable( + route = "office_viewer/{filePath}", + arguments = listOf(navArgument("filePath") { type = NavType.StringType }) + ) { backStackEntry -> + val encodedPath = backStackEntry.arguments?.getString("filePath") ?: return@composable + val filePath = URLDecoder.decode(encodedPath, StandardCharsets.UTF_8.toString()) + OfficeViewerScreen( + file = File(filePath), + onNavigateBack = { navController.popBackStack() } + ) + } + + composable( + route = "archive_viewer/{filePath}", + arguments = listOf(navArgument("filePath") { type = NavType.StringType }) + ) { backStackEntry -> + val encodedPath = backStackEntry.arguments?.getString("filePath") ?: return@composable + val filePath = URLDecoder.decode(encodedPath, StandardCharsets.UTF_8.toString()) + ArchiveViewerScreen( + file = File(filePath), + onNavigateBack = { navController.popBackStack() } + ) + } + } +} + +@Composable +fun PermissionCheckScreen( + onPermissionGranted: () -> Unit +) { + val context = androidx.compose.ui.platform.LocalContext.current + var hasPermission by remember { + mutableStateOf(PermissionUtils.hasStoragePermission(context)) + } + + LaunchedEffect(Unit) { + if (hasPermission) { + onPermissionGranted() + } + } + + if (!hasPermission) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(32.dp) + ) { + Text( + text = "Storage Permission Required", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "This app needs storage permission to browse and view your files.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.data = Uri.parse("package:${context.packageName}") + context.startActivity(intent) + } else { + ActivityCompat.requestPermissions( + context as ComponentActivity, + PermissionUtils.getRequiredPermissions(), + 100 + ) + } + } + ) { + Text("Grant Permission") + } + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + onClick = { + hasPermission = PermissionUtils.hasStoragePermission(context) + if (hasPermission) { + onPermissionGranted() + } + } + ) { + Text("Check Again") + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/AppInfo.kt b/app/src/main/java/com/alldocs/filemanager/model/AppInfo.kt new file mode 100644 index 0000000..d0a5bf8 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/AppInfo.kt @@ -0,0 +1,26 @@ +package com.alldocs.filemanager.model + +import android.graphics.drawable.Drawable + +data class AppInfo( + val packageName: String, + val appName: String, + val versionName: String, + val versionCode: Long, + val icon: Drawable?, + val apkPath: String, + val size: Long, + val installDate: Long, + val updateDate: Long, + val isSystemApp: Boolean +) { + val sizeFormatted: String + get() = formatFileSize(size) + + private fun formatFileSize(size: Long): String { + if (size <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB") + val digitGroups = (Math.log10(size.toDouble()) / Math.log10(1024.0)).toInt() + return String.format("%.1f %s", size / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups]) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/ArchiveEntry.kt b/app/src/main/java/com/alldocs/filemanager/model/ArchiveEntry.kt new file mode 100644 index 0000000..82368d8 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/ArchiveEntry.kt @@ -0,0 +1,17 @@ +package com.alldocs.filemanager.model + +data class ArchiveEntry( + val name: String, + val size: Long, + val isDirectory: Boolean +) { + val sizeFormatted: String + get() = formatFileSize(size) + + private fun formatFileSize(size: Long): String { + if (size <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (Math.log10(size.toDouble()) / Math.log10(1024.0)).toInt() + return String.format("%.1f %s", size / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups]) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/Bookmark.kt b/app/src/main/java/com/alldocs/filemanager/model/Bookmark.kt new file mode 100644 index 0000000..b406415 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/Bookmark.kt @@ -0,0 +1,15 @@ +package com.alldocs.filemanager.model + +import java.io.File + +data class Bookmark( + val id: String, + val name: String, + val path: String, + val file: File = File(path), + val icon: String? = null, + val order: Int = 0, + val createdAt: Long = System.currentTimeMillis() +) { + fun exists(): Boolean = file.exists() +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/CloudStorage.kt b/app/src/main/java/com/alldocs/filemanager/model/CloudStorage.kt new file mode 100644 index 0000000..7150b18 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/CloudStorage.kt @@ -0,0 +1,33 @@ +package com.alldocs.filemanager.model + +enum class CloudStorageType { + GOOGLE_DRIVE, + DROPBOX, + ONEDRIVE, + FTP, + SFTP, + SMB, + WEBDAV, + LOCAL +} + +data class CloudStorageAccount( + val id: String, + val name: String, + val type: CloudStorageType, + val host: String = "", + val port: Int = 0, + val username: String = "", + val password: String = "", + val rootPath: String = "", + val isConnected: Boolean = false +) + +data class RemoteFile( + val name: String, + val path: String, + val size: Long, + val isDirectory: Boolean, + val lastModified: Long, + val mimeType: String = "" +) \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/EncryptedFile.kt b/app/src/main/java/com/alldocs/filemanager/model/EncryptedFile.kt new file mode 100644 index 0000000..1ae5435 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/EncryptedFile.kt @@ -0,0 +1,20 @@ +package com.alldocs.filemanager.model + +import java.io.File + +data class EncryptedFile( + val originalFile: File, + val encryptedFile: File, + val encryptionTimestamp: Long, + val algorithm: String = "AES", + val keySize: Int = 256 +) + +data class SecureVault( + val id: String, + val name: String, + val path: String, + val isLocked: Boolean = true, + val encryptedFiles: List = emptyList(), + val createdAt: Long = System.currentTimeMillis() +) \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/FileItem.kt b/app/src/main/java/com/alldocs/filemanager/model/FileItem.kt new file mode 100644 index 0000000..c9e7f8b --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/FileItem.kt @@ -0,0 +1,49 @@ +package com.alldocs.filemanager.model + +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class FileItem( + val file: File, + val name: String = file.name, + val path: String = file.absolutePath, + val isDirectory: Boolean = file.isDirectory, + val size: Long = file.length(), + val lastModified: Long = file.lastModified(), + val extension: String = file.extension.lowercase(), + val fileType: FileType = FileType.fromExtension(file.extension) +) { + val sizeFormatted: String + get() = formatFileSize(size) + + val dateFormatted: String + get() = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(lastModified)) + + private fun formatFileSize(size: Long): String { + if (size <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (Math.log10(size.toDouble()) / Math.log10(1024.0)).toInt() + return String.format("%.1f %s", size / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups]) + } +} + +enum class FileType(val displayName: String, val extensions: List) { + PDF("PDF Document", listOf("pdf")), + WORD("Word Document", listOf("doc", "docx")), + EXCEL("Excel Spreadsheet", listOf("xls", "xlsx")), + POWERPOINT("PowerPoint Presentation", listOf("ppt", "pptx")), + ARCHIVE("Archive", listOf("zip", "rar", "7z", "tar", "gz", "bz2")), + TEXT("Text File", listOf("txt", "md", "log")), + IMAGE("Image", listOf("jpg", "jpeg", "png", "gif", "bmp", "webp")), + FOLDER("Folder", emptyList()), + UNKNOWN("Unknown", emptyList()); + + companion object { + fun fromExtension(ext: String): FileType { + val extension = ext.lowercase() + return values().find { it.extensions.contains(extension) } ?: UNKNOWN + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/StorageInfo.kt b/app/src/main/java/com/alldocs/filemanager/model/StorageInfo.kt new file mode 100644 index 0000000..0ad2eea --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/StorageInfo.kt @@ -0,0 +1,16 @@ +package com.alldocs.filemanager.model + +data class StorageInfo( + val totalSpace: Long, + val freeSpace: Long, + val usedSpace: Long, + val largestFiles: List = emptyList(), + val filesByType: Map = emptyMap(), + val duplicateFiles: List> = emptyList() +) { + val usedPercentage: Float + get() = if (totalSpace > 0) (usedSpace.toFloat() / totalSpace.toFloat()) * 100 else 0f + + val freePercentage: Float + get() = if (totalSpace > 0) (freeSpace.toFloat() / totalSpace.toFloat()) * 100 else 0f +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/model/TabItem.kt b/app/src/main/java/com/alldocs/filemanager/model/TabItem.kt new file mode 100644 index 0000000..c42ade4 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/model/TabItem.kt @@ -0,0 +1,16 @@ +package com.alldocs.filemanager.model + +import java.io.File +import java.util.UUID + +data class TabItem( + val id: String = UUID.randomUUID().toString(), + val title: String, + val currentDirectory: File, + val navigationHistory: MutableList = mutableListOf(currentDirectory), + val isActive: Boolean = false +) { + fun getDisplayTitle(): String { + return currentDirectory.name.ifEmpty { "Storage" } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/components/BookmarksDrawer.kt b/app/src/main/java/com/alldocs/filemanager/ui/components/BookmarksDrawer.kt new file mode 100644 index 0000000..9826415 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/components/BookmarksDrawer.kt @@ -0,0 +1,156 @@ +package com.alldocs.filemanager.ui.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.alldocs.filemanager.model.Bookmark +import com.alldocs.filemanager.viewmodel.BookmarkViewModel + +@Composable +fun BookmarksDrawer( + viewModel: BookmarkViewModel = viewModel(), + onBookmarkClick: (Bookmark) -> Unit +) { + val bookmarks by viewModel.bookmarks.collectAsState() + + ModalDrawerSheet { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + "Bookmarks", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(bottom = 16.dp) + ) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(bookmarks) { bookmark -> + BookmarkItem( + bookmark = bookmark, + onClick = { onBookmarkClick(bookmark) }, + onDelete = { viewModel.removeBookmark(bookmark.id) } + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Divider() + + Spacer(modifier = Modifier.height(16.dp)) + + // Quick Access Sections + QuickAccessItem( + icon = Icons.Default.Storage, + title = "Internal Storage", + onClick = { } + ) + QuickAccessItem( + icon = Icons.Default.Cloud, + title = "Cloud Storage", + onClick = { } + ) + QuickAccessItem( + icon = Icons.Default.Lock, + title = "Secure Vault", + onClick = { } + ) + QuickAccessItem( + icon = Icons.Default.Apps, + title = "App Manager", + onClick = { } + ) + } + } +} + +@Composable +fun BookmarkItem( + bookmark: Bookmark, + onClick: () -> Unit, + onDelete: () -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Folder, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = bookmark.name, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, "Options") + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Remove") }, + onClick = { + onDelete() + showMenu = false + }, + leadingIcon = { Icon(Icons.Default.Delete, null) } + ) + } + } +} + +@Composable +fun QuickAccessItem( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Text( + text = title, + style = MaterialTheme.typography.bodyLarge + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/components/DualPaneLayout.kt b/app/src/main/java/com/alldocs/filemanager/ui/components/DualPaneLayout.kt new file mode 100644 index 0000000..a887064 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/components/DualPaneLayout.kt @@ -0,0 +1,47 @@ +package com.alldocs.filemanager.ui.components + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Divider +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DualPaneLayout( + leftPane: @Composable () -> Unit, + rightPane: @Composable () -> Unit, + isLandscape: Boolean = true +) { + if (isLandscape) { + Row( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + leftPane() + } + + Divider( + modifier = Modifier + .fillMaxHeight() + .width(1.dp) + ) + + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + rightPane() + } + } + } else { + // Single pane in portrait + Box(modifier = Modifier.fillMaxSize()) { + leftPane() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/components/FileIcon.kt b/app/src/main/java/com/alldocs/filemanager/ui/components/FileIcon.kt new file mode 100644 index 0000000..b41e641 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/components/FileIcon.kt @@ -0,0 +1,56 @@ +package com.alldocs.filemanager.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +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.alldocs.filemanager.model.FileType + +@Composable +fun FileIcon( + fileType: FileType, + modifier: Modifier = Modifier +) { + val (icon, color) = getIconAndColor(fileType) + + Box( + modifier = modifier + .background( + color = color.copy(alpha = 0.1f), + shape = RoundedCornerShape(8.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = fileType.displayName, + tint = color, + modifier = Modifier.size(24.dp) + ) + } +} + +@Composable +private fun getIconAndColor(fileType: FileType): Pair { + return when (fileType) { + FileType.PDF -> Icons.Default.Description to Color(0xFFE53935) + FileType.WORD -> Icons.Default.Description to Color(0xFF1976D2) + FileType.EXCEL -> Icons.Default.TableChart to Color(0xFF388E3C) + FileType.POWERPOINT -> Icons.Default.Slideshow to Color(0xFFD84315) + FileType.ARCHIVE -> Icons.Default.FolderZip to Color(0xFFFBC02D) + FileType.TEXT -> Icons.Default.TextSnippet to Color(0xFF757575) + FileType.IMAGE -> Icons.Default.Image to Color(0xFF7B1FA2) + FileType.FOLDER -> Icons.Default.Folder to MaterialTheme.colorScheme.primary + FileType.UNKNOWN -> Icons.Default.InsertDriveFile to Color(0xFF9E9E9E) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/AppManagerScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/AppManagerScreen.kt new file mode 100644 index 0000000..8f8443b --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/AppManagerScreen.kt @@ -0,0 +1,156 @@ +package com.alldocs.filemanager.ui.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.alldocs.filemanager.model.AppInfo +import com.alldocs.filemanager.viewmodel.AppManagerViewModel +import com.alldocs.filemanager.viewmodel.LoadingState +import com.google.accompanist.drawablepainter.rememberDrawablePainter +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppManagerScreen( + viewModel: AppManagerViewModel = viewModel(), + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val apps by viewModel.apps.collectAsState() + val loadingState by viewModel.loadingState.collectAsState() + val showSystemApps by viewModel.showSystemApps.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadApps(context) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("App Manager (${apps.size})") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + IconButton(onClick = { viewModel.toggleSystemApps(context) }) { + Icon( + if (showSystemApps) Icons.Default.VisibilityOff else Icons.Default.Visibility, + "Toggle System Apps" + ) + } + IconButton(onClick = { viewModel.loadApps(context) }) { + Icon(Icons.Default.Refresh, "Refresh") + } + } + ) + } + ) { padding -> + when (loadingState) { + is LoadingState.Loading -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + else -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(8.dp) + ) { + items(apps, key = { it.packageName }) { app -> + AppListItem( + app = app, + onExtractClick = { + val downloadsDir = File("/storage/emulated/0/Download") + viewModel.extractApk(context, app.packageName, downloadsDir) + } + ) + } + } + } + } + } +} + +@Composable +fun AppListItem( + app: AppInfo, + onExtractClick: () -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { showMenu = true }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + app.icon?.let { icon -> + Image( + painter = rememberDrawablePainter(drawable = icon), + contentDescription = app.appName, + modifier = Modifier.size(48.dp) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = app.appName, + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "${app.versionName} • ${app.sizeFormatted}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, "Options") + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Extract APK") }, + onClick = { + onExtractClick() + showMenu = false + }, + leadingIcon = { Icon(Icons.Default.Save, null) } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/ArchiveViewerScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/ArchiveViewerScreen.kt new file mode 100644 index 0000000..b17ed7f --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/ArchiveViewerScreen.kt @@ -0,0 +1,222 @@ +package com.alldocs.filemanager.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.alldocs.filemanager.model.ArchiveEntry +import com.alldocs.filemanager.ui.components.FileIcon +import com.alldocs.filemanager.model.FileType +import com.alldocs.filemanager.util.ArchiveExtractor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ArchiveViewerScreen( + file: File, + onNavigateBack: () -> Unit +) { + val scope = rememberCoroutineScope() + var entries by remember { mutableStateOf>(emptyList()) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + var showExtractDialog by remember { mutableStateOf(false) } + var selectedEntry by remember { mutableStateOf(null) } + + LaunchedEffect(file) { + scope.launch { + try { + entries = withContext(Dispatchers.IO) { + ArchiveExtractor.listEntries(file) + } + isLoading = false + } catch (e: Exception) { + errorMessage = e.message ?: "Error reading archive" + isLoading = false + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = file.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "${entries.size} items", + style = MaterialTheme.typography.bodySmall + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + IconButton( + onClick = { + scope.launch { + try { + withContext(Dispatchers.IO) { + ArchiveExtractor.extractAll(file) + } + } catch (e: Exception) { + errorMessage = e.message + } + } + } + ) { + Icon(Icons.Default.FolderOpen, "Extract All") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + errorMessage != null -> { + Text( + text = errorMessage ?: "Error", + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.error + ) + } + entries.isEmpty() -> { + Text( + text = "Archive is empty", + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp) + ) { + items(entries) { entry -> + ArchiveEntryItem( + entry = entry, + onClick = { + selectedEntry = entry + showExtractDialog = true + } + ) + } + } + } + } + } + } + + if (showExtractDialog && selectedEntry != null) { + ExtractDialog( + entry = selectedEntry!!, + onDismiss = { showExtractDialog = false }, + onConfirm = { + scope.launch { + try { + withContext(Dispatchers.IO) { + ArchiveExtractor.extractEntry(file, selectedEntry!!) + } + showExtractDialog = false + } catch (e: Exception) { + errorMessage = e.message + } + } + } + ) + } +} + +@Composable +fun ArchiveEntryItem( + entry: ArchiveEntry, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FileIcon( + fileType = if (entry.isDirectory) FileType.FOLDER else FileType.fromExtension(entry.name.substringAfterLast(".", "")), + modifier = Modifier.size(40.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = entry.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (entry.isDirectory) "Folder" else entry.sizeFormatted, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +fun ExtractDialog( + entry: ArchiveEntry, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Extract File") }, + text = { Text("Extract \"${entry.name}\" to Downloads folder?") }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Extract") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/EncryptionVaultScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/EncryptionVaultScreen.kt new file mode 100644 index 0000000..106b01b --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/EncryptionVaultScreen.kt @@ -0,0 +1,206 @@ +package com.alldocs.filemanager.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.alldocs.filemanager.viewmodel.EncryptionState +import com.alldocs.filemanager.viewmodel.EncryptionViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EncryptionVaultScreen( + viewModel: EncryptionViewModel = viewModel(), + onNavigateBack: () -> Unit +) { + val vaults by viewModel.vaults.collectAsState() + val encryptionState by viewModel.encryptionState.collectAsState() + var showCreateDialog by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Secure Vault") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + IconButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, "Create Vault") + } + } + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { showCreateDialog = true }, + icon = { Icon(Icons.Default.Lock, "Create") }, + text = { Text("New Vault") } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when (encryptionState) { + is EncryptionState.Processing -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + if (vaults.isEmpty()) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Lock, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + "No vaults created", + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Create a vault to encrypt your files", + style = MaterialTheme.typography.bodyMedium + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(vaults) { vault -> + VaultCard( + vault = vault, + onUnlock = { viewModel.unlockVault(vault.id, "") }, + onLock = { viewModel.lockVault(vault.id) } + ) + } + } + } + } + } + } + } + + if (showCreateDialog) { + CreateVaultDialog( + onDismiss = { showCreateDialog = false }, + onConfirm = { name, password -> + val vaultDir = java.io.File("/storage/emulated/0/SecureVault/$name") + viewModel.createVault(name, vaultDir, password) + showCreateDialog = false + } + ) + } +} + +@Composable +fun VaultCard( + vault: com.alldocs.filemanager.model.SecureVault, + onUnlock: () -> Unit, + onLock: () -> Unit +) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (vault.isLocked) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = null, + modifier = Modifier.size(40.dp), + tint = if (vault.isLocked) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text( + text = vault.name, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "${vault.encryptedFiles.size} files • ${if (vault.isLocked) "Locked" else "Unlocked"}", + style = MaterialTheme.typography.bodySmall + ) + } + + Button( + onClick = { if (vault.isLocked) onUnlock() else onLock() } + ) { + Text(if (vault.isLocked) "Unlock" else "Lock") + } + } + } +} + +@Composable +fun CreateVaultDialog( + onDismiss: () -> Unit, + onConfirm: (String, String) -> Unit +) { + var vaultName by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Create Secure Vault") }, + text = { + Column { + TextField( + value = vaultName, + onValueChange = { vaultName = it }, + label = { Text("Vault Name") }, + singleLine = true + ) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + singleLine = true + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(vaultName, password) }, + enabled = vaultName.isNotBlank() && password.isNotBlank() + ) { + Text("Create") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/FileBrowserScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/FileBrowserScreen.kt new file mode 100644 index 0000000..57ecd5d --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/FileBrowserScreen.kt @@ -0,0 +1,347 @@ +package com.alldocs.filemanager.ui.screen + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +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.lifecycle.viewmodel.compose.viewModel +import com.alldocs.filemanager.model.FileItem +import com.alldocs.filemanager.model.FileType +import com.alldocs.filemanager.ui.components.FileIcon +import com.alldocs.filemanager.viewmodel.FileBrowserUiState +import com.alldocs.filemanager.viewmodel.FileBrowserViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FileBrowserScreen( + viewModel: FileBrowserViewModel = viewModel(), + onFileClick: (FileItem) -> Unit, + onNavigateUp: () -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + val currentDirectory by viewModel.currentDirectory.collectAsState() + val searchQuery by viewModel.searchQuery.collectAsState() + var showSearchBar by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.loadInitialDirectory() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + if (showSearchBar) { + TextField( + value = searchQuery, + onValueChange = { viewModel.searchFiles(it) }, + placeholder = { Text("Search files...") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface + ) + ) + } else { + Text( + text = currentDirectory?.name ?: "AllDocs FileManager", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + }, + navigationIcon = { + IconButton(onClick = { + if (showSearchBar) { + showSearchBar = false + viewModel.searchFiles("") + } else { + val canNavigateBack = viewModel.navigateBack() + if (!canNavigateBack) { + onNavigateUp() + } + } + }) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + if (!showSearchBar) { + IconButton(onClick = { showSearchBar = true }) { + Icon(Icons.Default.Search, "Search") + } + } + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, "More") + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Toggle hidden files") }, + onClick = { + viewModel.toggleHiddenFiles() + showMenu = false + }, + leadingIcon = { Icon(Icons.Default.Visibility, null) } + ) + DropdownMenuItem( + text = { Text("Refresh") }, + onClick = { + viewModel.refreshCurrentDirectory() + showMenu = false + }, + leadingIcon = { Icon(Icons.Default.Refresh, null) } + ) + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when (val state = uiState) { + is FileBrowserUiState.Loading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + is FileBrowserUiState.Success -> { + if (state.files.isEmpty()) { + Text( + text = "No files found", + modifier = Modifier.align(Alignment.Center), + style = MaterialTheme.typography.bodyLarge + ) + } else { + FileList( + files = state.files, + onFileClick = onFileClick, + onDeleteClick = { viewModel.deleteFile(it) }, + onRenameClick = { file, name -> viewModel.renameFile(file, name) } + ) + } + } + is FileBrowserUiState.Error -> { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = state.message, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } +} + +@Composable +fun FileList( + files: List, + onFileClick: (FileItem) -> Unit, + onDeleteClick: (FileItem) -> Unit, + onRenameClick: (FileItem, String) -> Unit +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(8.dp) + ) { + items(files, key = { it.path }) { file -> + FileListItem( + fileItem = file, + onClick = { onFileClick(file) }, + onDeleteClick = { onDeleteClick(file) }, + onRenameClick = { newName -> onRenameClick(file, newName) } + ) + } + } +} + +@Composable +fun FileListItem( + fileItem: FileItem, + onClick: () -> Unit, + onDeleteClick: () -> Unit, + onRenameClick: (String) -> Unit +) { + var showMenu by remember { mutableStateOf(false) } + var showRenameDialog by remember { mutableStateOf(false) } + var showDeleteDialog by remember { mutableStateOf(false) } + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onClick() }, + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + FileIcon( + fileType = fileItem.fileType, + modifier = Modifier.size(40.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = fileItem.name, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (fileItem.isDirectory) + fileItem.dateFormatted + else + "${fileItem.sizeFormatted} • ${fileItem.dateFormatted}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + IconButton(onClick = { showMenu = true }) { + Icon(Icons.Default.MoreVert, "Options") + } + + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Rename") }, + onClick = { + showRenameDialog = true + showMenu = false + }, + leadingIcon = { Icon(Icons.Default.Edit, null) } + ) + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + showDeleteDialog = true + showMenu = false + }, + leadingIcon = { Icon(Icons.Default.Delete, null) } + ) + } + } + } + + if (showRenameDialog) { + RenameDialog( + currentName = fileItem.name, + onDismiss = { showRenameDialog = false }, + onConfirm = { newName -> + onRenameClick(newName) + showRenameDialog = false + } + ) + } + + if (showDeleteDialog) { + DeleteConfirmDialog( + fileName = fileItem.name, + onDismiss = { showDeleteDialog = false }, + onConfirm = { + onDeleteClick() + showDeleteDialog = false + } + ) + } +} + +@Composable +fun RenameDialog( + currentName: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit +) { + var newName by remember { mutableStateOf(currentName) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename") }, + text = { + TextField( + value = newName, + onValueChange = { newName = it }, + label = { Text("New name") }, + singleLine = true + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(newName) }, + enabled = newName.isNotBlank() && newName != currentName + ) { + Text("Rename") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} + +@Composable +fun DeleteConfirmDialog( + fileName: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete File") }, + text = { Text("Are you sure you want to delete \"$fileName\"?") }, + confirmButton = { + TextButton( + onClick = onConfirm, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/OfficeViewerScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/OfficeViewerScreen.kt new file mode 100644 index 0000000..e44e66f --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/OfficeViewerScreen.kt @@ -0,0 +1,115 @@ +package com.alldocs.filemanager.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.alldocs.filemanager.util.FileUtils +import com.alldocs.filemanager.util.OfficeDocumentParser +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OfficeViewerScreen( + file: File, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var content by remember { mutableStateOf("") } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + LaunchedEffect(file) { + scope.launch { + try { + content = withContext(Dispatchers.IO) { + OfficeDocumentParser.parseDocument(file) + } + isLoading = false + } catch (e: Exception) { + errorMessage = e.message ?: "Error parsing document" + isLoading = false + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = file.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + IconButton(onClick = { FileUtils.shareFile(context, file) }) { + Icon(Icons.Default.Share, "Share") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + when { + isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + errorMessage != null -> { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Error loading document", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = errorMessage ?: "", + style = MaterialTheme.typography.bodyMedium + ) + } + } + else -> { + Text( + text = content, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/PdfViewerScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/PdfViewerScreen.kt new file mode 100644 index 0000000..919cd1e --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/PdfViewerScreen.kt @@ -0,0 +1,120 @@ +package com.alldocs.filemanager.ui.screen + +import android.view.ViewGroup +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import com.github.barteksc.pdfviewer.PDFView +import com.alldocs.filemanager.util.FileUtils +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PdfViewerScreen( + file: File, + onNavigateBack: () -> Unit +) { + val context = LocalContext.current + var currentPage by remember { mutableStateOf(0) } + var totalPages by remember { mutableStateOf(0) } + var isLoading by remember { mutableStateOf(true) } + var errorMessage by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = file.name, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (totalPages > 0) { + Text( + text = "Page ${currentPage + 1} of $totalPages", + style = MaterialTheme.typography.bodySmall + ) + } + } + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + IconButton(onClick = { FileUtils.shareFile(context, file) }) { + Icon(Icons.Default.Share, "Share") + } + } + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + if (errorMessage != null) { + Text( + text = errorMessage ?: "Error loading PDF", + modifier = Modifier.align(Alignment.Center), + color = MaterialTheme.colorScheme.error + ) + } else { + AndroidView( + factory = { ctx -> + PDFView(ctx, null).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + }, + modifier = Modifier.fillMaxSize() + ) { pdfView -> + try { + pdfView.fromFile(file) + .enableSwipe(true) + .swipeHorizontal(false) + .enableDoubletap(true) + .defaultPage(0) + .onPageChange { page, pageCount -> + currentPage = page + totalPages = pageCount + } + .enableAnnotationRendering(true) + .onLoad { + isLoading = false + } + .onError { throwable -> + errorMessage = throwable.message + isLoading = false + } + .spacing(10) + .load() + } catch (e: Exception) { + errorMessage = e.message + isLoading = false + } + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/StorageAnalyzerScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/StorageAnalyzerScreen.kt new file mode 100644 index 0000000..26c4da5 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/StorageAnalyzerScreen.kt @@ -0,0 +1,193 @@ +package com.alldocs.filemanager.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.alldocs.filemanager.viewmodel.AnalysisState +import com.alldocs.filemanager.viewmodel.StorageViewModel +import java.io.File + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StorageAnalyzerScreen( + viewModel: StorageViewModel = viewModel(), + onNavigateBack: () -> Unit +) { + val storageInfo by viewModel.storageInfo.collectAsState() + val analysisState by viewModel.analysisState.collectAsState() + val junkFiles by viewModel.junkFiles.collectAsState() + + LaunchedEffect(Unit) { + viewModel.analyzeStorage(File("/storage/emulated/0")) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Storage Analyzer") }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, "Back") + } + }, + actions = { + IconButton(onClick = { + viewModel.analyzeStorage(File("/storage/emulated/0")) + }) { + Icon(Icons.Default.Refresh, "Refresh") + } + } + ) + } + ) { padding -> + when (analysisState) { + is AnalysisState.Analyzing -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text("Analyzing storage...") + } + } + } + is AnalysisState.Complete -> { + storageInfo?.let { info -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Storage Overview + item { + StorageOverviewCard(info) + } + + // Largest Files + item { + Text( + "Largest Files", + style = MaterialTheme.typography.titleLarge + ) + } + + items(info.largestFiles.take(10)) { file -> + FileListItem( + fileItem = file, + onClick = { }, + onDeleteClick = { }, + onRenameClick = { } + ) + } + + // Junk Files Section + item { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text("Junk Files", style = MaterialTheme.typography.titleMedium) + Text("${junkFiles.size} files found") + } + Button( + onClick = { + viewModel.findJunkFiles(File("/storage/emulated/0")) + } + ) { + Text("Scan") + } + } + } + } + } + } + } + is AnalysisState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center + ) { + Text( + (analysisState as AnalysisState.Error).message, + color = MaterialTheme.colorScheme.error + ) + } + } + else -> {} + } + } +} + +@Composable +fun StorageOverviewCard(info: com.alldocs.filemanager.model.StorageInfo) { + Card( + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Storage Overview", style = MaterialTheme.typography.titleLarge) + + LinearProgressIndicator( + progress = { info.usedPercentage / 100f }, + modifier = Modifier + .fillMaxWidth() + .height(12.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text("Used", style = MaterialTheme.typography.bodySmall) + Text( + formatBytes(info.usedSpace), + style = MaterialTheme.typography.titleMedium + ) + } + Column(horizontalAlignment = Alignment.End) { + Text("Free", style = MaterialTheme.typography.bodySmall) + Text( + formatBytes(info.freeSpace), + style = MaterialTheme.typography.titleMedium + ) + } + } + } + } +} + +private fun formatBytes(bytes: Long): String { + if (bytes <= 0) return "0 B" + val units = arrayOf("B", "KB", "MB", "GB", "TB") + val digitGroups = (Math.log10(bytes.toDouble()) / Math.log10(1024.0)).toInt() + return String.format("%.1f %s", bytes / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups]) +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/screen/TabbedFileBrowserScreen.kt b/app/src/main/java/com/alldocs/filemanager/ui/screen/TabbedFileBrowserScreen.kt new file mode 100644 index 0000000..5ff4ec1 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/screen/TabbedFileBrowserScreen.kt @@ -0,0 +1,92 @@ +package com.alldocs.filemanager.ui.screen + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.alldocs.filemanager.model.FileItem +import com.alldocs.filemanager.viewmodel.FileBrowserViewModel +import com.alldocs.filemanager.viewmodel.TabViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TabbedFileBrowserScreen( + tabViewModel: TabViewModel = viewModel(), + onFileClick: (FileItem) -> Unit +) { + val tabs by tabViewModel.tabs.collectAsState() + val activeTabId by tabViewModel.activeTabId.collectAsState() + + Column(modifier = Modifier.fillMaxSize()) { + // Tab Bar + Surface( + modifier = Modifier.fillMaxWidth(), + tonalElevation = 3.dp + ) { + Row(modifier = Modifier.padding(8.dp)) { + LazyRow( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(tabs, key = { it.id }) { tab -> + TabChip( + title = tab.getDisplayTitle(), + isActive = tab.id == activeTabId, + onClick = { tabViewModel.setActiveTab(tab.id) }, + onClose = { tabViewModel.closeTab(tab.id) } + ) + } + } + + IconButton(onClick = { + tabViewModel.getActiveTab()?.let { activeTab -> + tabViewModel.addTab(activeTab.currentDirectory) + } + }) { + Icon(Icons.Default.Add, "Add Tab") + } + } + } + + // Active Tab Content + val activeTab = tabs.find { it.id == activeTabId } + if (activeTab != null) { + FileBrowserScreen( + onFileClick = onFileClick, + onNavigateUp = { } + ) + } + } +} + +@Composable +fun TabChip( + title: String, + isActive: Boolean, + onClick: () -> Unit, + onClose: () -> Unit +) { + FilterChip( + selected = isActive, + onClick = onClick, + label = { Text(title) }, + trailingIcon = { + IconButton( + onClick = onClose, + modifier = Modifier.size(20.dp) + ) { + Icon( + Icons.Default.Close, + "Close Tab", + modifier = Modifier.size(16.dp) + ) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/theme/Theme.kt b/app/src/main/java/com/alldocs/filemanager/ui/theme/Theme.kt new file mode 100644 index 0000000..b3926db --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/theme/Theme.kt @@ -0,0 +1,60 @@ +package com.alldocs.filemanager.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Color(0xFF90CAF9), + secondary = Color(0xFF81C784), + tertiary = Color(0xFFFFB74D) +) + +private val LightColorScheme = lightColorScheme( + primary = Color(0xFF1976D2), + secondary = Color(0xFF388E3C), + tertiary = Color(0xFFF57C00) +) + +@Composable +fun AllDocsFileManagerTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/ui/theme/Type.kt b/app/src/main/java/com/alldocs/filemanager/ui/theme/Type.kt new file mode 100644 index 0000000..9246594 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/ui/theme/Type.kt @@ -0,0 +1,31 @@ +package com.alldocs.filemanager.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/AppManager.kt b/app/src/main/java/com/alldocs/filemanager/util/AppManager.kt new file mode 100644 index 0000000..e910756 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/AppManager.kt @@ -0,0 +1,79 @@ +package com.alldocs.filemanager.util + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager +import com.alldocs.filemanager.model.AppInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +object AppManager { + + suspend fun getInstalledApps(context: Context, includeSystem: Boolean = false): List = withContext(Dispatchers.IO) { + val pm = context.packageManager + val packages = pm.getInstalledApplications(PackageManager.GET_META_DATA) + + packages.mapNotNull { appInfo -> + try { + if (!includeSystem && (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0) { + return@mapNotNull null + } + + val packageInfo = pm.getPackageInfo(appInfo.packageName, 0) + val apkFile = File(appInfo.sourceDir) + + AppInfo( + packageName = appInfo.packageName, + appName = pm.getApplicationLabel(appInfo).toString(), + versionName = packageInfo.versionName ?: "Unknown", + versionCode = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + @Suppress("DEPRECATION") + packageInfo.versionCode.toLong() + }, + icon = pm.getApplicationIcon(appInfo), + apkPath = appInfo.sourceDir, + size = apkFile.length(), + installDate = packageInfo.firstInstallTime, + updateDate = packageInfo.lastUpdateTime, + isSystemApp = (appInfo.flags and ApplicationInfo.FLAG_SYSTEM) != 0 + ) + } catch (e: Exception) { + null + } + }.sortedBy { it.appName } + } + + suspend fun extractApk(context: Context, packageName: String, destinationDir: File): File? = withContext(Dispatchers.IO) { + try { + val pm = context.packageManager + val appInfo = pm.getApplicationInfo(packageName, 0) + val sourceApk = File(appInfo.sourceDir) + + if (!destinationDir.exists()) { + destinationDir.mkdirs() + } + + val appName = pm.getApplicationLabel(appInfo).toString() + val destFile = File(destinationDir, "${appName}.apk") + + sourceApk.copyTo(destFile, overwrite = true) + destFile + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + suspend fun getAppSize(context: Context, packageName: String): Long = withContext(Dispatchers.IO) { + try { + val pm = context.packageManager + val appInfo = pm.getApplicationInfo(packageName, 0) + File(appInfo.sourceDir).length() + } catch (e: Exception) { + 0L + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/ArchiveExtractor.kt b/app/src/main/java/com/alldocs/filemanager/util/ArchiveExtractor.kt new file mode 100644 index 0000000..a03276f --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/ArchiveExtractor.kt @@ -0,0 +1,188 @@ +package com.alldocs.filemanager.util + +import android.os.Environment +import com.alldocs.filemanager.model.ArchiveEntry +import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipInputStream + +object ArchiveExtractor { + + fun listEntries(file: File): List { + return when (file.extension.lowercase()) { + "zip" -> listZipEntries(file) + "tar" -> listTarEntries(file) + "gz", "gzip" -> listGzipEntries(file) + else -> emptyList() + } + } + + private fun listZipEntries(file: File): List { + val entries = mutableListOf() + + try { + ZipInputStream(FileInputStream(file)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + entries.add( + ArchiveEntry( + name = entry.name, + size = entry.size, + isDirectory = entry.isDirectory + ) + ) + entry = zis.nextEntry + } + } + } catch (e: Exception) { + throw Exception("Error reading ZIP archive: ${e.message}") + } + + return entries + } + + private fun listTarEntries(file: File): List { + val entries = mutableListOf() + + try { + TarArchiveInputStream(FileInputStream(file)).use { tis -> + var entry = tis.nextEntry + while (entry != null) { + entries.add( + ArchiveEntry( + name = entry.name, + size = entry.size, + isDirectory = entry.isDirectory + ) + ) + entry = tis.nextEntry + } + } + } catch (e: Exception) { + throw Exception("Error reading TAR archive: ${e.message}") + } + + return entries + } + + private fun listGzipEntries(file: File): List { + // GZIP typically contains single file + return listOf( + ArchiveEntry( + name = file.nameWithoutExtension, + size = file.length(), + isDirectory = false + ) + ) + } + + fun extractAll(file: File) { + val outputDir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + file.nameWithoutExtension + ) + outputDir.mkdirs() + + when (file.extension.lowercase()) { + "zip" -> extractZip(file, outputDir) + "tar" -> extractTar(file, outputDir) + "gz", "gzip" -> extractGzip(file, outputDir) + } + } + + private fun extractZip(file: File, outputDir: File) { + ZipInputStream(FileInputStream(file)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val outputFile = File(outputDir, entry.name) + + if (entry.isDirectory) { + outputFile.mkdirs() + } else { + outputFile.parentFile?.mkdirs() + FileOutputStream(outputFile).use { fos -> + zis.copyTo(fos) + } + } + + entry = zis.nextEntry + } + } + } + + private fun extractTar(file: File, outputDir: File) { + TarArchiveInputStream(FileInputStream(file)).use { tis -> + var entry = tis.nextEntry + while (entry != null) { + val outputFile = File(outputDir, entry.name) + + if (entry.isDirectory) { + outputFile.mkdirs() + } else { + outputFile.parentFile?.mkdirs() + FileOutputStream(outputFile).use { fos -> + tis.copyTo(fos) + } + } + + entry = tis.nextEntry + } + } + } + + private fun extractGzip(file: File, outputDir: File) { + val outputFile = File(outputDir, file.nameWithoutExtension) + outputFile.parentFile?.mkdirs() + + GzipCompressorInputStream(FileInputStream(file)).use { gis -> + FileOutputStream(outputFile).use { fos -> + gis.copyTo(fos) + } + } + } + + fun extractEntry(archiveFile: File, entry: ArchiveEntry) { + val outputDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + val outputFile = File(outputDir, entry.name) + outputFile.parentFile?.mkdirs() + + when (archiveFile.extension.lowercase()) { + "zip" -> extractZipEntry(archiveFile, entry.name, outputFile) + "tar" -> extractTarEntry(archiveFile, entry.name, outputFile) + } + } + + private fun extractZipEntry(file: File, entryName: String, outputFile: File) { + ZipInputStream(FileInputStream(file)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (entry.name == entryName && !entry.isDirectory) { + FileOutputStream(outputFile).use { fos -> + zis.copyTo(fos) + } + break + } + entry = zis.nextEntry + } + } + } + + private fun extractTarEntry(file: File, entryName: String, outputFile: File) { + TarArchiveInputStream(FileInputStream(file)).use { tis -> + var entry = tis.nextEntry + while (entry != null) { + if (entry.name == entryName && !entry.isDirectory) { + FileOutputStream(outputFile).use { fos -> + tis.copyTo(fos) + } + break + } + entry = tis.nextEntry + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/EncryptionUtils.kt b/app/src/main/java/com/alldocs/filemanager/util/EncryptionUtils.kt new file mode 100644 index 0000000..1f62664 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/EncryptionUtils.kt @@ -0,0 +1,102 @@ +package com.alldocs.filemanager.util + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +object EncryptionUtils { + + private const val ALGORITHM = "AES" + private const val TRANSFORMATION = "AES/CBC/PKCS5Padding" + private const val KEY_SIZE = 256 + + fun generateKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance(ALGORITHM) + keyGenerator.init(KEY_SIZE) + return keyGenerator.generateKey() + } + + fun encryptFile(inputFile: File, outputFile: File, key: SecretKey): Boolean { + return try { + val cipher = Cipher.getInstance(TRANSFORMATION) + val iv = ByteArray(16) + SecureRandom().nextBytes(iv) + val ivSpec = IvParameterSpec(iv) + + cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec) + + FileInputStream(inputFile).use { fis -> + FileOutputStream(outputFile).use { fos -> + // Write IV first + fos.write(iv) + + // Encrypt and write data + val buffer = ByteArray(8192) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + val encryptedBytes = cipher.update(buffer, 0, bytesRead) + if (encryptedBytes != null) { + fos.write(encryptedBytes) + } + } + val finalBytes = cipher.doFinal() + if (finalBytes != null) { + fos.write(finalBytes) + } + } + } + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + fun decryptFile(inputFile: File, outputFile: File, key: SecretKey): Boolean { + return try { + FileInputStream(inputFile).use { fis -> + // Read IV + val iv = ByteArray(16) + fis.read(iv) + val ivSpec = IvParameterSpec(iv) + + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, ivSpec) + + FileOutputStream(outputFile).use { fos -> + val buffer = ByteArray(8192) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + val decryptedBytes = cipher.update(buffer, 0, bytesRead) + if (decryptedBytes != null) { + fos.write(decryptedBytes) + } + } + val finalBytes = cipher.doFinal() + if (finalBytes != null) { + fos.write(finalBytes) + } + } + } + true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + fun keyToString(key: SecretKey): String { + return android.util.Base64.encodeToString(key.encoded, android.util.Base64.DEFAULT) + } + + fun stringToKey(keyString: String): SecretKey { + val decodedKey = android.util.Base64.decode(keyString, android.util.Base64.DEFAULT) + return SecretKeySpec(decodedKey, 0, decodedKey.size, ALGORITHM) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/FTPClient.kt b/app/src/main/java/com/alldocs/filemanager/util/FTPClient.kt new file mode 100644 index 0000000..cb81784 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/FTPClient.kt @@ -0,0 +1,116 @@ +package com.alldocs.filemanager.util + +import com.alldocs.filemanager.model.RemoteFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.Socket +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter + +class FTPClient(private val host: String, private val port: Int = 21) { + + private var socket: Socket? = null + private var reader: BufferedReader? = null + private var writer: PrintWriter? = null + + suspend fun connect(username: String, password: String): Boolean = withContext(Dispatchers.IO) { + try { + socket = Socket(host, port) + reader = BufferedReader(InputStreamReader(socket?.getInputStream())) + writer = PrintWriter(socket?.getOutputStream(), true) + + // Read welcome message + reader?.readLine() + + // Send USER command + writer?.println("USER $username") + reader?.readLine() + + // Send PASS command + writer?.println("PASS $password") + val response = reader?.readLine() + + response?.startsWith("230") == true + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + suspend fun listFiles(path: String): List = withContext(Dispatchers.IO) { + try { + writer?.println("CWD $path") + reader?.readLine() + + writer?.println("PASV") + val pasvResponse = reader?.readLine() + + writer?.println("LIST") + reader?.readLine() + + // Parse file list (simplified) + val files = mutableListOf() + var line = reader?.readLine() + while (line != null && !line.startsWith("226")) { + // Parse FTP LIST format (basic parsing) + val parts = line.split("\\s+".toRegex()) + if (parts.size >= 9) { + val name = parts.subList(8, parts.size).joinToString(" ") + val isDir = line.startsWith("d") + files.add( + RemoteFile( + name = name, + path = "$path/$name", + size = parts.getOrNull(4)?.toLongOrNull() ?: 0, + isDirectory = isDir, + lastModified = System.currentTimeMillis() + ) + ) + } + line = reader?.readLine() + } + + files + } catch (e: Exception) { + e.printStackTrace() + emptyList() + } + } + + suspend fun downloadFile(remotePath: String, localFile: File): Boolean = withContext(Dispatchers.IO) { + try { + writer?.println("TYPE I") + reader?.readLine() + + writer?.println("RETR $remotePath") + val response = reader?.readLine() + + if (response?.startsWith("150") == true) { + FileOutputStream(localFile).use { fos -> + socket?.getInputStream()?.copyTo(fos) + } + true + } else { + false + } + } catch (e: Exception) { + e.printStackTrace() + false + } + } + + fun disconnect() { + try { + writer?.println("QUIT") + reader?.close() + writer?.close() + socket?.close() + } catch (e: Exception) { + e.printStackTrace() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/FileUtils.kt b/app/src/main/java/com/alldocs/filemanager/util/FileUtils.kt new file mode 100644 index 0000000..642b84d --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/FileUtils.kt @@ -0,0 +1,117 @@ +package com.alldocs.filemanager.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import androidx.core.content.FileProvider +import com.alldocs.filemanager.model.FileItem +import com.alldocs.filemanager.model.FileType +import java.io.File + +object FileUtils { + + fun getStorageDirectories(): List { + val directories = mutableListOf() + + // Primary external storage + val externalStorage = Environment.getExternalStorageDirectory() + if (externalStorage.exists()) { + directories.add(externalStorage) + } + + return directories + } + + fun getQuickAccessFolders(): List { + return listOf( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM), + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + ).filter { it.exists() } + } + + fun getFilesInDirectory(directory: File, showHidden: Boolean = false): List { + if (!directory.exists() || !directory.isDirectory) { + return emptyList() + } + + return directory.listFiles()?.filter { file -> + showHidden || !file.name.startsWith(".") + }?.map { FileItem(it) }?.sortedWith( + compareBy { !it.isDirectory }.thenBy { it.name.lowercase() } + ) ?: emptyList() + } + + fun searchFiles(directory: File, query: String, showHidden: Boolean = false): List { + val results = mutableListOf() + + fun searchRecursive(dir: File) { + dir.listFiles()?.forEach { file -> + if (showHidden || !file.name.startsWith(".")) { + if (file.name.lowercase().contains(query.lowercase())) { + results.add(FileItem(file)) + } + if (file.isDirectory) { + searchRecursive(file) + } + } + } + } + + searchRecursive(directory) + return results.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() }) + } + + fun deleteFile(file: File): Boolean { + return if (file.isDirectory) { + file.deleteRecursively() + } else { + file.delete() + } + } + + fun renameFile(file: File, newName: String): Boolean { + val newFile = File(file.parent, newName) + return file.renameTo(newFile) + } + + fun shareFile(context: Context, file: File) { + try { + val uri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = getMimeType(file) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + context.startActivity(Intent.createChooser(shareIntent, "Share file")) + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun getMimeType(file: File): String { + return when (FileType.fromExtension(file.extension)) { + FileType.PDF -> "application/pdf" + FileType.WORD -> "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + FileType.EXCEL -> "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + FileType.POWERPOINT -> "application/vnd.openxmlformats-officedocument.presentationml.presentation" + FileType.ARCHIVE -> when (file.extension.lowercase()) { + "zip" -> "application/zip" + "rar" -> "application/x-rar-compressed" + "7z" -> "application/x-7z-compressed" + else -> "application/octet-stream" + } + FileType.TEXT -> "text/plain" + FileType.IMAGE -> "image/*" + else -> "*/*" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/OfficeDocumentParser.kt b/app/src/main/java/com/alldocs/filemanager/util/OfficeDocumentParser.kt new file mode 100644 index 0000000..56a26cb --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/OfficeDocumentParser.kt @@ -0,0 +1,102 @@ +package com.alldocs.filemanager.util + +import org.apache.poi.xwpf.usermodel.XWPFDocument +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.apache.poi.xslf.usermodel.XMLSlideShow +import java.io.File +import java.io.FileInputStream + +object OfficeDocumentParser { + + fun parseDocument(file: File): String { + return when (file.extension.lowercase()) { + "docx" -> parseWordDocument(file) + "xlsx" -> parseExcelDocument(file) + "pptx" -> parsePowerPointDocument(file) + else -> "Unsupported document format" + } + } + + private fun parseWordDocument(file: File): String { + return try { + FileInputStream(file).use { fis -> + val document = XWPFDocument(fis) + val text = StringBuilder() + + document.paragraphs.forEach { paragraph -> + text.append(paragraph.text) + text.append("\n\n") + } + + document.close() + text.toString().ifEmpty { "Document is empty" } + } + } catch (e: Exception) { + "Error parsing Word document: ${e.message}" + } + } + + private fun parseExcelDocument(file: File): String { + return try { + FileInputStream(file).use { fis -> + val workbook = XSSFWorkbook(fis) + val text = StringBuilder() + + workbook.forEach { sheet -> + text.append("Sheet: ${sheet.sheetName}\n") + text.append("=".repeat(40)) + text.append("\n\n") + + sheet.forEach { row -> + row.forEach { cell -> + val cellValue = when { + cell.cellType.name == "STRING" -> cell.stringCellValue + cell.cellType.name == "NUMERIC" -> cell.numericCellValue.toString() + cell.cellType.name == "BOOLEAN" -> cell.booleanCellValue.toString() + cell.cellType.name == "FORMULA" -> cell.cellFormula + else -> "" + } + text.append(cellValue) + text.append("\t") + } + text.append("\n") + } + text.append("\n") + } + + workbook.close() + text.toString().ifEmpty { "Spreadsheet is empty" } + } + } catch (e: Exception) { + "Error parsing Excel document: ${e.message}" + } + } + + private fun parsePowerPointDocument(file: File): String { + return try { + FileInputStream(file).use { fis -> + val presentation = XMLSlideShow(fis) + val text = StringBuilder() + + presentation.slides.forEachIndexed { index, slide -> + text.append("Slide ${index + 1}\n") + text.append("=".repeat(40)) + text.append("\n\n") + + slide.shapes.forEach { shape -> + if (shape is org.apache.poi.xslf.usermodel.XSLFTextShape) { + text.append(shape.text) + text.append("\n") + } + } + text.append("\n") + } + + presentation.close() + text.toString().ifEmpty { "Presentation is empty" } + } + } catch (e: Exception) { + "Error parsing PowerPoint document: ${e.message}" + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/PermissionUtils.kt b/app/src/main/java/com/alldocs/filemanager/util/PermissionUtils.kt new file mode 100644 index 0000000..49cc62d --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/PermissionUtils.kt @@ -0,0 +1,39 @@ +package com.alldocs.filemanager.util + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Environment +import androidx.core.content.ContextCompat + +object PermissionUtils { + + fun hasStoragePermission(context: Context): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + ContextCompat.checkSelfPermission( + context, + Manifest.permission.READ_EXTERNAL_STORAGE + ) == PackageManager.PERMISSION_GRANTED + } else { + true + } + } + + fun getRequiredPermissions(): Array { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_AUDIO + ) + } else { + arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/util/StorageAnalyzer.kt b/app/src/main/java/com/alldocs/filemanager/util/StorageAnalyzer.kt new file mode 100644 index 0000000..acbe2a0 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/util/StorageAnalyzer.kt @@ -0,0 +1,121 @@ +package com.alldocs.filemanager.util + +import com.alldocs.filemanager.model.FileItem +import com.alldocs.filemanager.model.FileType +import com.alldocs.filemanager.model.StorageInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +object StorageAnalyzer { + + suspend fun analyzeStorage(rootDir: File): StorageInfo = withContext(Dispatchers.IO) { + val totalSpace = rootDir.totalSpace + val freeSpace = rootDir.freeSpace + val usedSpace = totalSpace - freeSpace + + val allFiles = mutableListOf() + val filesByType = mutableMapOf() + + scanDirectory(rootDir, allFiles, filesByType) + + // Sort by size to find largest files + val largestFiles = allFiles + .filter { !it.isDirectory } + .sortedByDescending { it.size } + .take(50) + + // Find duplicates by size and name + val duplicates = findDuplicates(allFiles) + + StorageInfo( + totalSpace = totalSpace, + freeSpace = freeSpace, + usedSpace = usedSpace, + largestFiles = largestFiles, + filesByType = filesByType, + duplicateFiles = duplicates + ) + } + + private fun scanDirectory( + dir: File, + allFiles: MutableList, + filesByType: MutableMap + ) { + dir.listFiles()?.forEach { file -> + try { + val fileItem = FileItem(file) + allFiles.add(fileItem) + + if (!file.isDirectory) { + val currentSize = filesByType.getOrDefault(fileItem.fileType, 0L) + filesByType[fileItem.fileType] = currentSize + file.length() + } else { + scanDirectory(file, allFiles, filesByType) + } + } catch (e: Exception) { + // Skip inaccessible files + } + } + } + + private fun findDuplicates(files: List): List> { + return files + .filter { !it.isDirectory } + .groupBy { "${it.name}_${it.size}" } + .filter { it.value.size > 1 } + .values + .toList() + } + + suspend fun findLargeFiles(dir: File, minSizeMB: Long = 10): List = withContext(Dispatchers.IO) { + val minSize = minSizeMB * 1024 * 1024 + val largeFiles = mutableListOf() + + fun scan(directory: File) { + directory.listFiles()?.forEach { file -> + try { + if (file.isDirectory) { + scan(file) + } else if (file.length() >= minSize) { + largeFiles.add(FileItem(file)) + } + } catch (e: Exception) { + // Skip + } + } + } + + scan(dir) + largeFiles.sortedByDescending { it.size } + } + + suspend fun findJunkFiles(dir: File): List = withContext(Dispatchers.IO) { + val junkPatterns = listOf( + ".tmp", ".temp", ".cache", ".log", ".bak", ".old", + "~", ".thumbnails", ".trash" + ) + + val junkFiles = mutableListOf() + + fun scan(directory: File) { + directory.listFiles()?.forEach { file -> + try { + val lowerName = file.name.lowercase() + if (junkPatterns.any { lowerName.endsWith(it) || lowerName.contains(it) }) { + junkFiles.add(FileItem(file)) + } + if (file.isDirectory) { + scan(file) + } + } catch (e: Exception) { + // Skip + } + } + } + + scan(dir) + junkFiles + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/viewmodel/AppManagerViewModel.kt b/app/src/main/java/com/alldocs/filemanager/viewmodel/AppManagerViewModel.kt new file mode 100644 index 0000000..a17de8c --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/viewmodel/AppManagerViewModel.kt @@ -0,0 +1,73 @@ +package com.alldocs.filemanager.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alldocs.filemanager.model.AppInfo +import com.alldocs.filemanager.util.AppManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +class AppManagerViewModel : ViewModel() { + + private val _apps = MutableStateFlow>(emptyList()) + val apps: StateFlow> = _apps.asStateFlow() + + private val _loadingState = MutableStateFlow(LoadingState.Idle) + val loadingState: StateFlow = _loadingState.asStateFlow() + + private val _showSystemApps = MutableStateFlow(false) + val showSystemApps: StateFlow = _showSystemApps.asStateFlow() + + fun loadApps(context: Context) { + viewModelScope.launch { + try { + _loadingState.value = LoadingState.Loading + val appList = AppManager.getInstalledApps(context, _showSystemApps.value) + _apps.value = appList + _loadingState.value = LoadingState.Success + } catch (e: Exception) { + _loadingState.value = LoadingState.Error(e.message ?: "Failed to load apps") + } + } + } + + fun toggleSystemApps(context: Context) { + _showSystemApps.value = !_showSystemApps.value + loadApps(context) + } + + fun extractApk(context: Context, packageName: String, destinationDir: File) { + viewModelScope.launch { + try { + _loadingState.value = LoadingState.Loading + val apkFile = AppManager.extractApk(context, packageName, destinationDir) + if (apkFile != null) { + _loadingState.value = LoadingState.Success + } else { + _loadingState.value = LoadingState.Error("Failed to extract APK") + } + } catch (e: Exception) { + _loadingState.value = LoadingState.Error(e.message ?: "Extraction failed") + } + } + } + + fun searchApps(query: String) { + if (query.isBlank()) return + _apps.value = _apps.value.filter { app -> + app.appName.contains(query, ignoreCase = true) || + app.packageName.contains(query, ignoreCase = true) + } + } +} + +sealed class LoadingState { + object Idle : LoadingState() + object Loading : LoadingState() + object Success : LoadingState() + data class Error(val message: String) : LoadingState() +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/viewmodel/BookmarkViewModel.kt b/app/src/main/java/com/alldocs/filemanager/viewmodel/BookmarkViewModel.kt new file mode 100644 index 0000000..3180030 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/viewmodel/BookmarkViewModel.kt @@ -0,0 +1,80 @@ +package com.alldocs.filemanager.viewmodel + +import androidx.lifecycle.ViewModel +import com.alldocs.filemanager.model.Bookmark +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.util.UUID + +class BookmarkViewModel : ViewModel() { + + private val _bookmarks = MutableStateFlow>(emptyList()) + val bookmarks: StateFlow> = _bookmarks.asStateFlow() + + init { + loadDefaultBookmarks() + } + + private fun loadDefaultBookmarks() { + val defaults = listOf( + Bookmark( + id = UUID.randomUUID().toString(), + name = "Downloads", + path = "/storage/emulated/0/Download", + order = 0 + ), + Bookmark( + id = UUID.randomUUID().toString(), + name = "Documents", + path = "/storage/emulated/0/Documents", + order = 1 + ), + Bookmark( + id = UUID.randomUUID().toString(), + name = "Pictures", + path = "/storage/emulated/0/Pictures", + order = 2 + ), + Bookmark( + id = UUID.randomUUID().toString(), + name = "DCIM", + path = "/storage/emulated/0/DCIM", + order = 3 + ) + ) + _bookmarks.value = defaults + } + + fun addBookmark(name: String, file: File) { + val bookmark = Bookmark( + id = UUID.randomUUID().toString(), + name = name, + path = file.absolutePath, + order = _bookmarks.value.size + ) + _bookmarks.value = _bookmarks.value + bookmark + } + + fun removeBookmark(bookmarkId: String) { + _bookmarks.value = _bookmarks.value.filter { it.id != bookmarkId } + } + + fun updateBookmark(bookmarkId: String, newName: String) { + _bookmarks.value = _bookmarks.value.map { bookmark -> + if (bookmark.id == bookmarkId) { + bookmark.copy(name = newName) + } else bookmark + } + } + + fun reorderBookmarks(fromIndex: Int, toIndex: Int) { + val mutableList = _bookmarks.value.toMutableList() + val item = mutableList.removeAt(fromIndex) + mutableList.add(toIndex, item) + _bookmarks.value = mutableList.mapIndexed { index, bookmark -> + bookmark.copy(order = index) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/viewmodel/DocumentViewerViewModel.kt b/app/src/main/java/com/alldocs/filemanager/viewmodel/DocumentViewerViewModel.kt new file mode 100644 index 0000000..621080b --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/viewmodel/DocumentViewerViewModel.kt @@ -0,0 +1,52 @@ +package com.alldocs.filemanager.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +class DocumentViewerViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(DocumentViewerUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _currentFile = MutableStateFlow(null) + val currentFile: StateFlow = _currentFile.asStateFlow() + + private val _currentPage = MutableStateFlow(0) + val currentPage: StateFlow = _currentPage.asStateFlow() + + fun loadDocument(file: File) { + _currentFile.value = file + _uiState.value = DocumentViewerUiState.Loading + + viewModelScope.launch { + try { + // Document loading is handled by specific viewers + _uiState.value = DocumentViewerUiState.Ready + } catch (e: Exception) { + _uiState.value = DocumentViewerUiState.Error(e.message ?: "Error loading document") + } + } + } + + fun setCurrentPage(page: Int) { + _currentPage.value = page + } + + fun closeDocument() { + _currentFile.value = null + _currentPage.value = 0 + _uiState.value = DocumentViewerUiState.Idle + } +} + +sealed class DocumentViewerUiState { + object Idle : DocumentViewerUiState() + object Loading : DocumentViewerUiState() + object Ready : DocumentViewerUiState() + data class Error(val message: String) : DocumentViewerUiState() +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/viewmodel/EncryptionViewModel.kt b/app/src/main/java/com/alldocs/filemanager/viewmodel/EncryptionViewModel.kt new file mode 100644 index 0000000..9e0b35f --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/viewmodel/EncryptionViewModel.kt @@ -0,0 +1,136 @@ +package com.alldocs.filemanager.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alldocs.filemanager.model.EncryptedFile +import com.alldocs.filemanager.model.SecureVault +import com.alldocs.filemanager.util.EncryptionUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.util.UUID +import javax.crypto.SecretKey + +class EncryptionViewModel : ViewModel() { + + private val _vaults = MutableStateFlow>(emptyList()) + val vaults: StateFlow> = _vaults.asStateFlow() + + private val _encryptionState = MutableStateFlow(EncryptionState.Idle) + val encryptionState: StateFlow = _encryptionState.asStateFlow() + + private var currentKey: SecretKey? = null + + fun createVault(name: String, vaultPath: File, password: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + _encryptionState.value = EncryptionState.Processing + + vaultPath.mkdirs() + + val key = EncryptionUtils.generateKey() + currentKey = key + + val vault = SecureVault( + id = UUID.randomUUID().toString(), + name = name, + path = vaultPath.absolutePath, + isLocked = false + ) + + _vaults.value = _vaults.value + vault + _encryptionState.value = EncryptionState.Success("Vault created successfully") + } catch (e: Exception) { + _encryptionState.value = EncryptionState.Error(e.message ?: "Failed to create vault") + } + } + } + + fun encryptFile(file: File, vaultId: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + _encryptionState.value = EncryptionState.Processing + + val vault = _vaults.value.find { it.id == vaultId } + if (vault == null) { + _encryptionState.value = EncryptionState.Error("Vault not found") + return@launch + } + + val key = currentKey ?: EncryptionUtils.generateKey().also { currentKey = it } + + val encryptedFile = File(vault.path, "${file.name}.encrypted") + val success = EncryptionUtils.encryptFile(file, encryptedFile, key) + + if (success) { + val encFileInfo = EncryptedFile( + originalFile = file, + encryptedFile = encryptedFile, + encryptionTimestamp = System.currentTimeMillis() + ) + + _vaults.value = _vaults.value.map { v -> + if (v.id == vaultId) { + v.copy(encryptedFiles = v.encryptedFiles + encFileInfo) + } else v + } + + _encryptionState.value = EncryptionState.Success("File encrypted successfully") + } else { + _encryptionState.value = EncryptionState.Error("Encryption failed") + } + } catch (e: Exception) { + _encryptionState.value = EncryptionState.Error(e.message ?: "Encryption error") + } + } + } + + fun decryptFile(encryptedFile: File, outputFile: File) { + viewModelScope.launch(Dispatchers.IO) { + try { + _encryptionState.value = EncryptionState.Processing + + val key = currentKey + if (key == null) { + _encryptionState.value = EncryptionState.Error("No key available") + return@launch + } + + val success = EncryptionUtils.decryptFile(encryptedFile, outputFile, key) + + if (success) { + _encryptionState.value = EncryptionState.Success("File decrypted successfully") + } else { + _encryptionState.value = EncryptionState.Error("Decryption failed") + } + } catch (e: Exception) { + _encryptionState.value = EncryptionState.Error(e.message ?: "Decryption error") + } + } + } + + fun lockVault(vaultId: String) { + _vaults.value = _vaults.value.map { vault -> + if (vault.id == vaultId) vault.copy(isLocked = true) else vault + } + currentKey = null + } + + fun unlockVault(vaultId: String, password: String) { + // In production, validate password and load key + _vaults.value = _vaults.value.map { vault -> + if (vault.id == vaultId) vault.copy(isLocked = false) else vault + } + currentKey = EncryptionUtils.generateKey() + } +} + +sealed class EncryptionState { + object Idle : EncryptionState() + object Processing : EncryptionState() + data class Success(val message: String) : EncryptionState() + data class Error(val message: String) : EncryptionState() +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/viewmodel/FileBrowserViewModel.kt b/app/src/main/java/com/alldocs/filemanager/viewmodel/FileBrowserViewModel.kt new file mode 100644 index 0000000..0614af3 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/viewmodel/FileBrowserViewModel.kt @@ -0,0 +1,131 @@ +package com.alldocs.filemanager.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alldocs.filemanager.model.FileItem +import com.alldocs.filemanager.util.FileUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File +import java.util.Stack + +class FileBrowserViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(FileBrowserUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _currentDirectory = MutableStateFlow(null) + val currentDirectory: StateFlow = _currentDirectory.asStateFlow() + + private val _navigationStack = Stack() + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _showHidden = MutableStateFlow(false) + val showHidden: StateFlow = _showHidden.asStateFlow() + + fun loadInitialDirectory() { + val storageDir = FileUtils.getStorageDirectories().firstOrNull() + if (storageDir != null) { + navigateToDirectory(storageDir) + } else { + _uiState.value = FileBrowserUiState.Error("No storage found") + } + } + + fun navigateToDirectory(directory: File) { + if (!directory.exists() || !directory.isDirectory) { + _uiState.value = FileBrowserUiState.Error("Directory does not exist") + return + } + + _uiState.value = FileBrowserUiState.Loading + + viewModelScope.launch(Dispatchers.IO) { + try { + val files = FileUtils.getFilesInDirectory(directory, _showHidden.value) + _currentDirectory.value = directory + _navigationStack.push(directory) + _uiState.value = FileBrowserUiState.Success(files) + } catch (e: Exception) { + _uiState.value = FileBrowserUiState.Error(e.message ?: "Error loading directory") + } + } + } + + fun navigateBack(): Boolean { + if (_navigationStack.size > 1) { + _navigationStack.pop() + val previousDir = _navigationStack.peek() + navigateToDirectory(previousDir) + return true + } + return false + } + + fun refreshCurrentDirectory() { + _currentDirectory.value?.let { navigateToDirectory(it) } + } + + fun searchFiles(query: String) { + _searchQuery.value = query + + if (query.isBlank()) { + refreshCurrentDirectory() + return + } + + _uiState.value = FileBrowserUiState.Loading + + viewModelScope.launch(Dispatchers.IO) { + try { + val searchRoot = _currentDirectory.value ?: return@launch + val results = FileUtils.searchFiles(searchRoot, query, _showHidden.value) + _uiState.value = FileBrowserUiState.Success(results) + } catch (e: Exception) { + _uiState.value = FileBrowserUiState.Error(e.message ?: "Search failed") + } + } + } + + fun toggleHiddenFiles() { + _showHidden.value = !_showHidden.value + refreshCurrentDirectory() + } + + fun deleteFile(fileItem: FileItem) { + viewModelScope.launch(Dispatchers.IO) { + try { + val success = FileUtils.deleteFile(fileItem.file) + if (success) { + refreshCurrentDirectory() + } + } catch (e: Exception) { + _uiState.value = FileBrowserUiState.Error(e.message ?: "Delete failed") + } + } + } + + fun renameFile(fileItem: FileItem, newName: String) { + viewModelScope.launch(Dispatchers.IO) { + try { + val success = FileUtils.renameFile(fileItem.file, newName) + if (success) { + refreshCurrentDirectory() + } + } catch (e: Exception) { + _uiState.value = FileBrowserUiState.Error(e.message ?: "Rename failed") + } + } + } +} + +sealed class FileBrowserUiState { + object Loading : FileBrowserUiState() + data class Success(val files: List) : FileBrowserUiState() + data class Error(val message: String) : FileBrowserUiState() +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/viewmodel/StorageViewModel.kt b/app/src/main/java/com/alldocs/filemanager/viewmodel/StorageViewModel.kt new file mode 100644 index 0000000..daa8444 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/viewmodel/StorageViewModel.kt @@ -0,0 +1,70 @@ +package com.alldocs.filemanager.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.alldocs.filemanager.model.FileItem +import com.alldocs.filemanager.model.StorageInfo +import com.alldocs.filemanager.util.StorageAnalyzer +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.io.File + +class StorageViewModel : ViewModel() { + + private val _storageInfo = MutableStateFlow(null) + val storageInfo: StateFlow = _storageInfo.asStateFlow() + + private val _analysisState = MutableStateFlow(AnalysisState.Idle) + val analysisState: StateFlow = _analysisState.asStateFlow() + + private val _junkFiles = MutableStateFlow>(emptyList()) + val junkFiles: StateFlow> = _junkFiles.asStateFlow() + + fun analyzeStorage(rootDir: File) { + viewModelScope.launch { + try { + _analysisState.value = AnalysisState.Analyzing + val info = StorageAnalyzer.analyzeStorage(rootDir) + _storageInfo.value = info + _analysisState.value = AnalysisState.Complete + } catch (e: Exception) { + _analysisState.value = AnalysisState.Error(e.message ?: "Analysis failed") + } + } + } + + fun findJunkFiles(rootDir: File) { + viewModelScope.launch { + try { + _analysisState.value = AnalysisState.Analyzing + val junk = StorageAnalyzer.findJunkFiles(rootDir) + _junkFiles.value = junk + _analysisState.value = AnalysisState.Complete + } catch (e: Exception) { + _analysisState.value = AnalysisState.Error(e.message ?: "Search failed") + } + } + } + + fun deleteJunkFiles() { + viewModelScope.launch { + _junkFiles.value.forEach { fileItem -> + try { + fileItem.file.delete() + } catch (e: Exception) { + // Continue with next file + } + } + _junkFiles.value = emptyList() + } + } +} + +sealed class AnalysisState { + object Idle : AnalysisState() + object Analyzing : AnalysisState() + object Complete : AnalysisState() + data class Error(val message: String) : AnalysisState() +} \ No newline at end of file diff --git a/app/src/main/java/com/alldocs/filemanager/viewmodel/TabViewModel.kt b/app/src/main/java/com/alldocs/filemanager/viewmodel/TabViewModel.kt new file mode 100644 index 0000000..c6be9a5 --- /dev/null +++ b/app/src/main/java/com/alldocs/filemanager/viewmodel/TabViewModel.kt @@ -0,0 +1,73 @@ +package com.alldocs.filemanager.viewmodel + +import androidx.lifecycle.ViewModel +import com.alldocs.filemanager.model.TabItem +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File + +class TabViewModel : ViewModel() { + + private val _tabs = MutableStateFlow>(emptyList()) + val tabs: StateFlow> = _tabs.asStateFlow() + + private val _activeTabId = MutableStateFlow(null) + val activeTabId: StateFlow = _activeTabId.asStateFlow() + + init { + // Create initial tab + val initialTab = TabItem( + title = "Home", + currentDirectory = File("/storage/emulated/0"), + isActive = true + ) + _tabs.value = listOf(initialTab) + _activeTabId.value = initialTab.id + } + + fun addTab(directory: File) { + val newTab = TabItem( + title = directory.name.ifEmpty { "Storage" }, + currentDirectory = directory, + isActive = false + ) + _tabs.value = _tabs.value + newTab + } + + fun closeTab(tabId: String) { + val currentTabs = _tabs.value + if (currentTabs.size <= 1) return // Keep at least one tab + + _tabs.value = currentTabs.filter { it.id != tabId } + + if (_activeTabId.value == tabId) { + _activeTabId.value = _tabs.value.firstOrNull()?.id + } + } + + fun setActiveTab(tabId: String) { + _activeTabId.value = tabId + _tabs.value = _tabs.value.map { tab -> + tab.copy(isActive = tab.id == tabId) + } + } + + fun updateTabDirectory(tabId: String, directory: File) { + _tabs.value = _tabs.value.map { tab -> + if (tab.id == tabId) { + tab.copy( + currentDirectory = directory, + title = directory.name.ifEmpty { "Storage" }, + navigationHistory = tab.navigationHistory.apply { add(directory) } + ) + } else { + tab + } + } + } + + fun getActiveTab(): TabItem? { + return _tabs.value.find { it.id == _activeTabId.value } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..4d74961 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,20 @@ + + + AllDocs FileManager + Browse Files + Recent Files + Settings + Search files… + No files found + Storage permission required + Grant Permission + Open + Delete + Rename + Share + Extract + File Info + Loading… + Error loading file + Unsupported file format + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..2b8e815 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +