From a67ca94be7a71817524eddcb363d49576532da3a Mon Sep 17 00:00:00 2001 From: Nino Nakano Date: Mon, 27 Jul 2026 11:08:40 -0600 Subject: [PATCH] Initial commit - Folder Redirect App --- .gitignore | 99 +++ README.md | 112 +++ app/build.gradle | 48 ++ app/proguard-rules.pro | 2 + app/src/main/AndroidManifest.xml | 62 ++ .../folderredirect/app/ActivityLogEntry.kt | 22 + .../folderredirect/app/ActivityLogManager.kt | 49 ++ .../folderredirect/app/ExecuteTasksService.kt | 289 +++++++ .../app/ExecuteTasksWidgetProvider.kt | 96 +++ .../com/folderredirect/app/FileMoveWorker.kt | 258 ++++++ .../java/com/folderredirect/app/FileUtils.kt | 61 ++ .../app/FolderMonitorService.kt | 277 +++++++ .../java/com/folderredirect/app/FolderRule.kt | 55 ++ .../folderredirect/app/ImmichUploadRule.kt | 53 ++ .../com/folderredirect/app/ImmichUploader.kt | 229 ++++++ .../com/folderredirect/app/MainActivity.kt | 758 ++++++++++++++++++ .../com/folderredirect/app/RulesManager.kt | 118 +++ .../folderredirect/app/UnifiedRuleAdapter.kt | 276 +++++++ .../app/WorkManagerScheduler.kt | 54 ++ app/src/main/res/drawable/ic_folder.xml | 11 + .../res/drawable/ic_launcher_foreground.xml | 31 + .../main/res/drawable/widget_background.xml | 9 + app/src/main/res/layout/activity_main.xml | 72 ++ .../main/res/layout/dialog_immich_rule.xml | 210 +++++ app/src/main/res/layout/dialog_log_viewer.xml | 64 ++ app/src/main/res/layout/dialog_rule.xml | 184 +++++ app/src/main/res/layout/item_folder_rule.xml | 214 +++++ app/src/main/res/layout/tab_rules.xml | 23 + app/src/main/res/layout/tab_settings.xml | 167 ++++ .../main/res/layout/widget_execute_tasks.xml | 35 + app/src/main/res/menu/menu_main.xml | 8 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 9243 bytes .../mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 34808 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 9243 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 4931 bytes .../mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 17880 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 4931 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 14785 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 55330 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 14785 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 28626 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 105856 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 28626 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 45745 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 169714 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 45745 bytes app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/values/themes.xml | 7 + .../res/xml/widget_execute_tasks_info.xml | 11 + build.gradle | 8 + gradle.properties | 15 + gradle/wrapper/gradle-wrapper.properties | 5 + settings.gradle | 17 + todo.txt | 5 + 57 files changed, 4040 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/build.gradle create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/folderredirect/app/ActivityLogEntry.kt create mode 100644 app/src/main/java/com/folderredirect/app/ActivityLogManager.kt create mode 100644 app/src/main/java/com/folderredirect/app/ExecuteTasksService.kt create mode 100644 app/src/main/java/com/folderredirect/app/ExecuteTasksWidgetProvider.kt create mode 100644 app/src/main/java/com/folderredirect/app/FileMoveWorker.kt create mode 100644 app/src/main/java/com/folderredirect/app/FileUtils.kt create mode 100644 app/src/main/java/com/folderredirect/app/FolderMonitorService.kt create mode 100644 app/src/main/java/com/folderredirect/app/FolderRule.kt create mode 100644 app/src/main/java/com/folderredirect/app/ImmichUploadRule.kt create mode 100644 app/src/main/java/com/folderredirect/app/ImmichUploader.kt create mode 100644 app/src/main/java/com/folderredirect/app/MainActivity.kt create mode 100644 app/src/main/java/com/folderredirect/app/RulesManager.kt create mode 100644 app/src/main/java/com/folderredirect/app/UnifiedRuleAdapter.kt create mode 100644 app/src/main/java/com/folderredirect/app/WorkManagerScheduler.kt create mode 100644 app/src/main/res/drawable/ic_folder.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/widget_background.xml create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 app/src/main/res/layout/dialog_immich_rule.xml create mode 100644 app/src/main/res/layout/dialog_log_viewer.xml create mode 100644 app/src/main/res/layout/dialog_rule.xml create mode 100644 app/src/main/res/layout/item_folder_rule.xml create mode 100644 app/src/main/res/layout/tab_rules.xml create mode 100644 app/src/main/res/layout/tab_settings.xml create mode 100644 app/src/main/res/layout/widget_execute_tasks.xml create mode 100644 app/src/main/res/menu/menu_main.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/widget_execute_tasks_info.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle create mode 100644 todo.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78bacac --- /dev/null +++ b/.gitignore @@ -0,0 +1,99 @@ +# Built application files +*.apk +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ +.idea/workspace.xml +.idea/tasks.xml +.idea/gradle.xml +.idea/assetWizardSettings.xml +.idea/dictionaries +.idea/libraries +.idea/caches +.idea/modules.xml +.idea/misc.xml +.idea/inspectionProfiles/ + +# Keystore files +*.jks +*.keystore +!keystore.properties + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ + +# Mac OS +.DS_Store + +# Windows +Thumbs.db + +# Backup files +*~ +*.bak +*.swp + +# Temporary files +*.tmp +*.temp + +# Android Profiling +*.hprof diff --git a/README.md b/README.md new file mode 100644 index 0000000..7096be7 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Folder Redirect App + +Aplicación Android para redireccionar archivos entre carpetas automáticamente. + +## Características + +- ✅ Agregar reglas de redirección (carpeta origen → carpeta destino) +- ✅ Editar reglas existentes +- ✅ Eliminar reglas +- ✅ Activar/desactivar reglas individualmente +- ✅ **Filtro por tipo de archivo** (Documentos, Imágenes, Videos) +- ✅ **Filtro por patrón de nombre** (ej: RDT*, invoice_*, *.pdf) +- ✅ **Solución híbrida de batería**: Polling rápido cuando la app está abierta + WorkManager en segundo plano +- ✅ Servicio en segundo plano que monitorea las carpetas +- ✅ Mueve archivos automáticamente cuando llegan a la carpeta de origen +- ✅ Manejo de nombres duplicados (agrega timestamp) + +## Requisitos + +- Android 7.0 (API 24) o superior +- Permisos de almacenamiento + +## Permisos + +La app solicita los siguientes permisos: + +- `READ_EXTERNAL_STORAGE` - Leer archivos de la carpeta de origen +- `WRITE_EXTERNAL_STORAGE` - Escribir archivos en la carpeta de destino +- `MANAGE_EXTERNAL_STORAGE` - Acceso completo al almacenamiento (Android 11+) +- `FOREGROUND_SERVICE` - Ejecutar servicio en segundo plano + +## Cómo usar + +1. **Abrir la app** - Al iniciar, solicita permisos de almacenamiento + +2. **Agregar regla**: + - Presiona el botón flotante `+` + - Ingresa la ruta absoluta de la carpeta de origen (ej: `/storage/emulated/0/Download`) + - Ingresa la ruta absoluta de la carpeta de destino (ej: `/storage/emulated/0/MisDocumentos`) + - Presiona "Guardar" + +3. **Iniciar servicio**: + - Presiona el botón "Iniciar Servicio" + - El servicio se ejecutará en segundo plano monitoreando las carpetas + +4. **Gestionar reglas**: + - Usa el switch para activar/desactivar reglas individualmente + - Presiona el ícono de editar para modificar una regla + - Presiona el ícono de eliminar para borrar una regla + +## Notas Importantes + +- Las carpetas de origen y destino deben existir antes de crear la regla +- El servicio se detiene al cerrar la app (presionar "Detener Servicio") +- Los archivos con nombres duplicados reciben un timestamp para evitar sobrescritura +- Solo se monitorean archivos nuevos, no subcarpetas + +## Estructura del Proyecto + +``` +app/src/main/java/com/folderredirect/app/ +├── MainActivity.kt # Actividad principal +├── FolderRule.kt # Modelo de datos +├── FolderRuleAdapter.kt # Adapter para RecyclerView +├── FolderMonitorService.kt # Servicio de monitoreo +└── RulesManager.kt # Gestión de almacenamiento de reglas + +app/src/main/res/layout/ +├── activity_main.xml # Layout principal +├── item_folder_rule.xml # Item de la lista +└── dialog_rule.xml # Diálogo para agregar/editar +``` + +## Compilación + +```bash +# Abrir en Android Studio o compilar desde terminal +./gradlew assembleDebug + +# El APK se generará en: +# app/build/outputs/apk/debug/app-debug.apk +``` + +## Tecnologías + +- Kotlin +- Androidx +- Material Design 3 (Dynamic Colors) +- RecyclerView +- Foreground Service +- **WorkManager** (segundo plano eficiente) +- WatchService (Java NIO) +- Gson (persistencia) + +## 💡 Solución Híbrida de Batería + +La app usa un enfoque inteligente para ahorrar batería: + +### **Cuando la app está abierta:** +- ⚡ **Polling rápido (10 segundos)** - Movimiento casi instantáneo +- 📱 El servicio en primer plano monitorea constantemente + +### **Cuando la app está cerrada:** +- 🔋 **WorkManager (15 minutos)** - Bajo consumo +- 🔄 El sistema ejecuta verificaciones periódicas eficientes +- 💤 Respeta el modo Doze y ahorro de batería + +### **Ventajas:** +- ✅ Movimiento rápido cuando usas la app +- ✅ Ahorro de batería cuando está cerrada +- ✅ Funciona incluso si cierras la app +- ✅ Android no mata el proceso en segundo plano diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..0bcb995 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,48 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.folderredirect.app' + compileSdk 34 + + defaultConfig { + applicationId "com.folderredirect.app" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + viewBinding true + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'androidx.activity:activity-ktx:1.8.2' + implementation 'androidx.work:work-runtime-ktx:2.9.0' + implementation 'com.squareup.okhttp3:okhttp:4.12.0' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..c9fc529 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +-dontwarn org.jetbrains.kotlin.** +-keep class com.folderredirect.app.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b20c5f7 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/folderredirect/app/ActivityLogEntry.kt b/app/src/main/java/com/folderredirect/app/ActivityLogEntry.kt new file mode 100644 index 0000000..c79aa7b --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/ActivityLogEntry.kt @@ -0,0 +1,22 @@ +package com.folderredirect.app + +import java.io.Serializable +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +data class ActivityLogEntry( + val timestamp: Long = System.currentTimeMillis(), + val message: String, + val type: String +) : Serializable { + fun formattedTime(): String { + val sdf = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + return sdf.format(Date(timestamp)) + } + + fun formattedDate(): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + return sdf.format(Date(timestamp)) + } +} diff --git a/app/src/main/java/com/folderredirect/app/ActivityLogManager.kt b/app/src/main/java/com/folderredirect/app/ActivityLogManager.kt new file mode 100644 index 0000000..2639ecf --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/ActivityLogManager.kt @@ -0,0 +1,49 @@ +package com.folderredirect.app + +import android.content.Context +import android.content.SharedPreferences +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken + +class ActivityLogManager(context: Context) { + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val gson = Gson() + + companion object { + private const val PREFS_NAME = "activity_log_prefs" + private const val KEY_LOGS = "activity_logs" + private const val MAX_LOGS = 200 + } + + fun getLogs(): List { + val json = prefs.getString(KEY_LOGS, null) + return if (json != null) { + val type = object : TypeToken>() {}.type + gson.fromJson(json, type) + } else { + emptyList() + } + } + + fun addLog(entry: ActivityLogEntry) { + val logs = getLogs().toMutableList() + logs.add(entry) + if (logs.size > MAX_LOGS) { + val excess = logs.size - MAX_LOGS + val trimmed = logs.drop(excess) + saveLogs(trimmed) + } else { + saveLogs(logs) + } + } + + fun clearLogs() { + prefs.edit().remove(KEY_LOGS).apply() + } + + private fun saveLogs(logs: List) { + val json = gson.toJson(logs) + prefs.edit().putString(KEY_LOGS, json).apply() + } +} diff --git a/app/src/main/java/com/folderredirect/app/ExecuteTasksService.kt b/app/src/main/java/com/folderredirect/app/ExecuteTasksService.kt new file mode 100644 index 0000000..c888c70 --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/ExecuteTasksService.kt @@ -0,0 +1,289 @@ +package com.folderredirect.app + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.util.Log +import kotlinx.coroutines.* +import java.io.File +import kotlin.concurrent.thread + +class ExecuteTasksService : Service() { + + private val rulesManager by lazy { RulesManager(this) } + private val activityLogManager by lazy { ActivityLogManager(this) } + private val immichUploaders = mutableMapOf() + + companion object { + private const val TAG = "ExecuteTasksService" + } + + override fun onCreate() { + super.onCreate() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == "EXECUTE_AND_STOP") { + executeAllTasksAndStop() + } + return START_NOT_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + super.onDestroy() + Log.d(TAG, "Servicio destruido") + } + + private fun executeAllTasksAndStop() { + thread { + try { + Log.d(TAG, "Procesando reglas de carpetas...") + + val rules = rulesManager.getAllRules().filter { it.isEnabled } + var totalFilesMoved = 0 + + rules.forEach { rule -> + val count = moveExistingFilesForRule(rule) + totalFilesMoved += count + Log.d(TAG, "Archivos movidos: $totalFilesMoved") + rulesManager.updateFolderRuleProgress(rule.id, 100) + } + + Log.d(TAG, "Procesando reglas de Immich...") + + val immichRules = rulesManager.getAllImmichRules().filter { it.isEnabled } + var totalFilesUploaded = 0 + + immichRules.forEach { rule -> + val count = runBlocking { uploadExistingFilesForImmichRule(rule) } + totalFilesUploaded += count + Log.d(TAG, "Archivos subidos: $totalFilesUploaded") + rulesManager.updateImmichRuleProgress(rule.id, 100) + } + + val totalProcessed = totalFilesMoved + totalFilesUploaded + Log.d(TAG, "Completado: $totalProcessed archivos procesados") + + val completeIntent = Intent(this, ExecuteTasksWidgetProvider::class.java).apply { + action = ExecuteTasksWidgetProvider.ACTION_TASKS_COMPLETE + } + sendBroadcast(completeIntent) + + stopSelf() + + Log.d(TAG, "Servicio detenido completamente") + + } catch (e: Exception) { + Log.e(TAG, "Error ejecutando tareas: ${e.message}", e) + stopSelf() + } + } + } + + private fun moveExistingFilesForRule(rule: FolderRule): Int { + try { + val sourceDir = File(rule.sourcePath) + if (!sourceDir.exists() || !sourceDir.isDirectory) return 0 + + var count = 0 + val filesToProcess = sourceDir.listFiles()?.filter { it.isFile }?.filter { shouldMoveFile(it.name, rule) } ?: emptyList() + val totalFiles = filesToProcess.size + + if (totalFiles == 0) { + rulesManager.updateFolderRuleProgress(rule.id, 100) + sendProgressUpdate() + return 0 + } + + rulesManager.updateFolderRuleProgress(rule.id, 0) + + val progressPerFile = 100.0 / totalFiles + + filesToProcess.forEach { file -> + if (!file.exists()) { + Log.d(TAG, "Archivo ya no existe, saltando: ${file.name}") + count++ + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateFolderRuleProgress(rule.id, progress) + sendProgressUpdate() + return@forEach + } + moveFile(file, rule) + count++ + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateFolderRuleProgress(rule.id, progress) + sendProgressUpdate() + } + + return count + } catch (e: Exception) { + Log.e(TAG, "Error procesando regla: ${e.message}") + return 0 + } + } + + private suspend fun uploadExistingFilesForImmichRule(rule: ImmichUploadRule): Int { + return withContext(Dispatchers.IO) { + try { + val sourceDir = File(rule.sourcePath) + if (!sourceDir.exists() || !sourceDir.isDirectory) return@withContext 0 + + val uploader = ImmichUploader(rule.getNormalizedImmichUrl(), rule.apiKey) + val allFiles = getAllFilesRecursively(sourceDir) + val filesToProcess = allFiles.filter { shouldMoveFileImmich(it.name, rule) } + val totalFiles = filesToProcess.size + + if (totalFiles == 0) { + rulesManager.updateImmichRuleProgress(rule.id, 100) + sendProgressUpdate() + return@withContext 0 + } + + rulesManager.updateImmichRuleProgress(rule.id, 0) + + val progressPerFile = 100.0 / totalFiles + var count = 0 + + filesToProcess.forEach { file -> + if (!file.exists()) { + Log.d(TAG, "Archivo ya no existe, saltando: ${file.name}") + count++ + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateImmichRuleProgress(rule.id, progress) + sendProgressUpdate() + return@forEach + } + + val relativePath = file.absolutePath.substringAfter(sourceDir.absolutePath.trimEnd('/'), "").trimStart('/') + val albumName = getAlbumNameFromRelativePath(relativePath) + + var retries = 3 + var success = false + + while (retries > 0 && !success) { + try { + val result = uploader.uploadAsset(file, albumName) + if (result.success) { + val parentFolder = file.parentFile + file.delete() + deleteEmptyParentFolders(parentFolder, sourceDir) + count++ + success = true + } else { + Log.e(TAG, "Error subiendo ${file.name}: ${result.error}") + retries-- + if (retries > 0) Thread.sleep(3000) + } + } catch (e: java.net.SocketException) { + Log.e(TAG, "Connection reset subiendo ${file.name}, reintentando en 3s... (${retries} intentos)") + retries-- + if (retries > 0) Thread.sleep(3000) + } catch (e: Exception) { + Log.e(TAG, "Error subiendo ${file.name}: ${e.message}") + retries-- + if (retries > 0) Thread.sleep(3000) + } + } + + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateImmichRuleProgress(rule.id, progress) + sendProgressUpdate() + } + + return@withContext count + } catch (e: Exception) { + Log.e(TAG, "Error procesando regla Immich: ${e.message}") + return@withContext 0 + } + } + } + + private fun getAllFilesRecursively(dir: File): List { + val files = mutableListOf() + dir.listFiles()?.forEach { file -> + if (file.isFile) { + files.add(file) + } else if (file.isDirectory) { + files.addAll(getAllFilesRecursively(file)) + } + } + return files + } + + private fun getAlbumNameFromRelativePath(relativePath: String): String? = + FileUtils.getAlbumNameFromRelativePath(relativePath) + + private fun deleteEmptyParentFolders(folder: File?, baseFolder: File) = + FileUtils.deleteEmptyParentFolders(folder, baseFolder) + + private fun matchesFileType(fileName: String, category: FileTypeCategory): Boolean = + FileUtils.matchesFileType(fileName, category) + + private fun matchesNamePattern(fileName: String, pattern: String?): Boolean = + FileUtils.matchesNamePattern(fileName, pattern) + + private fun shouldMoveFile(fileName: String, rule: FolderRule): Boolean { + if (!FileUtils.matchesFileType(fileName, rule.getCategory())) return false + if (!FileUtils.matchesNamePattern(fileName, rule.getPattern())) return false + return true + } + + private fun shouldMoveFileImmich(fileName: String, rule: ImmichUploadRule): Boolean { + if (!FileUtils.matchesFileType(fileName, rule.getCategory())) return false + if (!FileUtils.matchesNamePattern(fileName, rule.getPattern())) return false + return true + } + + private fun moveFile(file: File, rule: FolderRule) { + try { + var destFile = File(rule.destinationPath, file.name) + + val destDir = File(rule.destinationPath) + if (!destDir.exists()) { + destDir.mkdirs() + } + + if (destFile.exists()) { + val timestamp = System.currentTimeMillis() + val nameWithoutExt = file.name.substringBeforeLast(".") + val ext = file.name.substringAfterLast(".", "") + val newFileName = if (ext.isNotEmpty()) { + "${nameWithoutExt}_$timestamp.$ext" + } else { + "${file.name}_$timestamp" + } + destFile = File(rule.destinationPath, newFileName) + } + + file.copyTo(destFile, overwrite = false) + file.delete() + + activityLogManager.addLog(ActivityLogEntry( + message = "Movido: ${file.name} → ${destFile.absolutePath}", + type = "MOVE" + )) + + Log.d(TAG, "Archivo movido: ${file.name} -> ${destFile.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Error moviendo archivo ${file.name}: ${e.message}") + } + } + + private fun sendProgressUpdate() { + try { + val globalIntent = Intent("com.folderredirect.app.PROGRESS_UPDATE") + sendBroadcast(globalIntent) + + val localIntent = Intent("com.folderredirect.app.PROGRESS_UPDATE_LOCAL") + androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).sendBroadcast(localIntent) + + val serviceIntent = Intent(this, FolderMonitorService::class.java) + serviceIntent.action = "UPDATE_PROGRESS_NOTIFICATION" + startService(serviceIntent) + } catch (e: Exception) { + Log.e(TAG, "Error enviando broadcast: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/folderredirect/app/ExecuteTasksWidgetProvider.kt b/app/src/main/java/com/folderredirect/app/ExecuteTasksWidgetProvider.kt new file mode 100644 index 0000000..a69d6ec --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/ExecuteTasksWidgetProvider.kt @@ -0,0 +1,96 @@ +package com.folderredirect.app + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import android.widget.Toast + +class ExecuteTasksWidgetProvider : AppWidgetProvider() { + + companion object { + const val ACTION_EXECUTE_TASKS = "com.folderredirect.app.ACTION_EXECUTE_TASKS" + const val ACTION_TASKS_COMPLETE = "com.folderredirect.app.ACTION_TASKS_COMPLETE" + } + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + private fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + val views = RemoteViews(context.packageName, R.layout.widget_execute_tasks) + + val executeIntent = Intent(context, ExecuteTasksWidgetProvider::class.java).apply { + action = ACTION_EXECUTE_TASKS + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + views.setOnClickPendingIntent(R.id.btnExecuteTasks, + android.app.PendingIntent.getBroadcast( + context, + appWidgetId, + executeIntent, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_IMMUTABLE + ) + ) + + views.setTextViewText(R.id.textStatus, "Listo") + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + + when (intent.action) { + ACTION_EXECUTE_TASKS -> { + val appWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + updateWidgetStatus(context, appWidgetId, "Ejecutando...") + + val serviceIntent = Intent(context, FolderMonitorService::class.java) + serviceIntent.action = "EXECUTE_AND_STOP" + context.startService(serviceIntent) + + Toast.makeText(context, "Ejecutando tareas...", Toast.LENGTH_SHORT).show() + } + } + + ACTION_TASKS_COMPLETE -> { + val appWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + + if (appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + updateWidgetStatus(context, appWidgetId, "Completado") + + android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + updateWidgetStatus(context, appWidgetId, "Listo") + }, 3000) + } + } + } + } + + private fun updateWidgetStatus(context: Context, appWidgetId: Int, status: String) { + val appWidgetManager = AppWidgetManager.getInstance(context) + val views = RemoteViews(context.packageName, R.layout.widget_execute_tasks) + views.setTextViewText(R.id.textStatus, status) + appWidgetManager.updateAppWidget(appWidgetId, views) + } +} diff --git a/app/src/main/java/com/folderredirect/app/FileMoveWorker.kt b/app/src/main/java/com/folderredirect/app/FileMoveWorker.kt new file mode 100644 index 0000000..58c059c --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/FileMoveWorker.kt @@ -0,0 +1,258 @@ +package com.folderredirect.app + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class FileMoveWorker(appContext: Context, params: WorkerParameters) : CoroutineWorker(appContext, params) { + + private val rulesManager = RulesManager(appContext) + + companion object { + private const val TAG = "FileMoveWorker" + } + + override suspend fun doWork(): Result { + Log.d(TAG, "Ejecutando worker en segundo plano...") + + try { + val rules = rulesManager.getAllRules().filter { it.isEnabled } + + if (rules.isEmpty()) { + Log.d(TAG, "No hay reglas activas") + } else { + var filesMoved = 0 + rules.forEach { rule -> + val count = moveFilesForRule(rule) + filesMoved += count + } + Log.d(TAG, "Worker completado. Archivos movidos: $filesMoved") + } + + val immichRules = rulesManager.getAllImmichRules().filter { it.isEnabled } + if (immichRules.isEmpty()) { + Log.d(TAG, "No hay reglas Immich activas") + } else { + var filesUploaded = 0 + immichRules.forEach { rule -> + val count = uploadFilesForImmichRule(rule) + filesUploaded += count + } + Log.d(TAG, "Worker Immich completado. Archivos subidos: $filesUploaded") + } + + rules.forEach { rule -> + rulesManager.updateFolderRuleProgress(rule.id, 100) + } + immichRules.forEach { rule -> + rulesManager.updateImmichRuleProgress(rule.id, 100) + } + sendProgressUpdate() + + return Result.success() + + } catch (e: Exception) { + Log.e(TAG, "Error en worker: ${e.message}", e) + return if (runAttemptCount < 3) { + Result.retry() + } else { + Result.failure() + } + } + } + + private fun moveFilesForRule(rule: FolderRule): Int { + try { + val sourceDir = File(rule.sourcePath) + if (!sourceDir.exists() || !sourceDir.isDirectory) return 0 + + val filesToProcess = sourceDir.listFiles()?.filter { it.isFile }?.filter { shouldMoveFile(it.name, rule) } ?: emptyList() + val totalFiles = filesToProcess.size + + if (totalFiles == 0) { + rulesManager.updateFolderRuleProgress(rule.id, 100) + sendProgressUpdate() + return 0 + } + + rulesManager.updateFolderRuleProgress(rule.id, 0) + + val progressPerFile = 100.0 / totalFiles + var count = 0 + + filesToProcess.forEach { file -> + if (!file.exists()) { + Log.d(TAG, "Archivo ya no existe, saltando: ${file.name}") + count++ + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateFolderRuleProgress(rule.id, progress) + sendProgressUpdate() + return@forEach + } + moveFile(file, rule) + count++ + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateFolderRuleProgress(rule.id, progress) + sendProgressUpdate() + } + + return count + } catch (e: Exception) { + Log.e(TAG, "Error procesando regla: ${e.message}") + return 0 + } + } + + private suspend fun uploadFilesForImmichRule(rule: ImmichUploadRule): Int { + return withContext(Dispatchers.IO) { + try { + val sourceDir = File(rule.sourcePath) + if (!sourceDir.exists() || !sourceDir.isDirectory) return@withContext 0 + + val uploader = ImmichUploader(rule.getNormalizedImmichUrl(), rule.apiKey) + val allFiles = getAllFilesRecursively(sourceDir) + val filesToProcess = allFiles.filter { shouldMoveFileImmich(it.name, rule) } + val totalFiles = filesToProcess.size + + if (totalFiles == 0) { + rulesManager.updateImmichRuleProgress(rule.id, 100) + sendProgressUpdate() + return@withContext 0 + } + + rulesManager.updateImmichRuleProgress(rule.id, 0) + + val progressPerFile = 100.0 / totalFiles + var count = 0 + + filesToProcess.forEach { file -> + if (!file.exists()) { + Log.d(TAG, "Archivo ya no existe, saltando: ${file.name}") + count++ + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateImmichRuleProgress(rule.id, progress) + sendProgressUpdate() + return@forEach + } + + val relativePath = file.absolutePath.substringAfter(sourceDir.absolutePath.trimEnd('/'), "").trimStart('/') + val albumName = getAlbumNameFromRelativePathImmich(relativePath) + + var retries = 3 + var success = false + + while (retries > 0 && !success) { + try { + val result = uploader.uploadAsset(file, albumName) + if (result.success) { + val parentFolder = file.parentFile + file.delete() + deleteEmptyParentFoldersImmich(parentFolder, sourceDir) + count++ + success = true + } else { + Log.e(TAG, "Error subiendo ${file.name}: ${result.error}") + retries-- + if (retries > 0) Thread.sleep(3000) + } + } catch (e: java.net.SocketException) { + Log.e(TAG, "Connection reset subiendo ${file.name}, reintentando en 3s... (${retries} intentos)") + retries-- + if (retries > 0) Thread.sleep(3000) + } catch (e: Exception) { + Log.e(TAG, "Error subiendo ${file.name}: ${e.message}") + retries-- + if (retries > 0) Thread.sleep(3000) + } + } + + val progress = ((progressPerFile * count).toInt()).coerceAtMost(100) + rulesManager.updateImmichRuleProgress(rule.id, progress) + sendProgressUpdate() + } + + return@withContext count + } catch (e: Exception) { + Log.e(TAG, "Error procesando regla Immich: ${e.message}") + return@withContext 0 + } + } + } + + private fun deleteEmptyParentFoldersImmich(folder: File?, baseFolder: File) = + FileUtils.deleteEmptyParentFolders(folder, baseFolder) + + private fun getAllFilesRecursively(dir: File): List = + FileUtils.getAllFilesRecursively(dir) + + private fun getAlbumNameFromRelativePathImmich(relativePath: String): String? = + FileUtils.getAlbumNameFromRelativePath(relativePath) + + private fun matchesFileType(fileName: String, category: FileTypeCategory): Boolean = + FileUtils.matchesFileType(fileName, category) + + private fun matchesNamePattern(fileName: String, pattern: String?): Boolean = + FileUtils.matchesNamePattern(fileName, pattern) + + private fun shouldMoveFile(fileName: String, rule: FolderRule): Boolean { + if (!FileUtils.matchesFileType(fileName, rule.getCategory())) return false + if (!FileUtils.matchesNamePattern(fileName, rule.getPattern())) return false + return true + } + + private fun shouldMoveFileImmich(fileName: String, rule: ImmichUploadRule): Boolean { + if (!FileUtils.matchesFileType(fileName, rule.getCategory())) return false + if (!FileUtils.matchesNamePattern(fileName, rule.getPattern())) return false + return true + } + + private fun moveFile(file: File, rule: FolderRule) { + try { + var destFile = File(rule.destinationPath, file.name) + + val destDir = File(rule.destinationPath) + if (!destDir.exists()) { + destDir.mkdirs() + } + + if (destFile.exists()) { + val timestamp = System.currentTimeMillis() + val nameWithoutExt = file.name.substringBeforeLast(".") + val ext = file.name.substringAfterLast(".", "") + val newFileName = if (ext.isNotEmpty()) { + "${nameWithoutExt}_$timestamp.$ext" + } else { + "${file.name}_$timestamp" + } + destFile = File(rule.destinationPath, newFileName) + } + + file.copyTo(destFile, overwrite = false) + file.delete() + + Log.d(TAG, "Archivo movido: ${file.name} -> ${destFile.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Error moviendo archivo ${file.name}: ${e.message}") + } + } + + private fun sendProgressUpdate() { + try { + val globalIntent = android.content.Intent("com.folderredirect.app.PROGRESS_UPDATE") + applicationContext.sendBroadcast(globalIntent) + + val localIntent = android.content.Intent("com.folderredirect.app.PROGRESS_UPDATE_LOCAL") + androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(localIntent) + + val serviceIntent = android.content.Intent(applicationContext, FolderMonitorService::class.java) + serviceIntent.action = "UPDATE_PROGRESS_NOTIFICATION" + applicationContext.startService(serviceIntent) + } catch (e: Exception) { + Log.e(TAG, "Error enviando broadcast: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/folderredirect/app/FileUtils.kt b/app/src/main/java/com/folderredirect/app/FileUtils.kt new file mode 100644 index 0000000..5ee3505 --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/FileUtils.kt @@ -0,0 +1,61 @@ +package com.folderredirect.app + +import android.util.Log +import java.io.File + +object FileUtils { + + private const val TAG = "FileUtils" + + fun matchesFileType(fileName: String, category: FileTypeCategory): Boolean { + if (category == FileTypeCategory.ALL) return true + val extension = fileName.substringAfterLast(".", "").lowercase() + return category.extensions.contains(extension) + } + + fun matchesNamePattern(fileName: String, pattern: String?): Boolean { + if (pattern.isNullOrBlank()) return true + val regexPattern = pattern + .replace(".", "\\.", ) + .replace("*", ".*") + .replace("?", ".") + return fileName.matches(regexPattern.toRegex(RegexOption.IGNORE_CASE)) + } + + fun getAlbumNameFromRelativePath(relativePath: String): String? { + val parts = relativePath.split("/") + if (parts.size <= 1) return null + val folderParts = parts.dropLast(1) + val albumName = folderParts.joinToString("/") + return if (albumName.isNotEmpty()) albumName else null + } + + fun deleteEmptyParentFolders(folder: File?, baseFolder: File) { + var currentFolder = folder + while (currentFolder != null && + currentFolder != baseFolder && + currentFolder.canonicalPath != baseFolder.canonicalPath) { + if (currentFolder.exists() && + currentFolder.isDirectory && + currentFolder.listFiles().isEmpty()) { + currentFolder.delete() + Log.d(TAG, "Carpeta vacía eliminada: ${currentFolder.absolutePath}") + currentFolder = currentFolder.parentFile + } else { + break + } + } + } + + fun getAllFilesRecursively(dir: File): List { + val files = mutableListOf() + dir.listFiles()?.forEach { file -> + if (file.isFile) { + files.add(file) + } else if (file.isDirectory) { + files.addAll(getAllFilesRecursively(file)) + } + } + return files + } +} diff --git a/app/src/main/java/com/folderredirect/app/FolderMonitorService.kt b/app/src/main/java/com/folderredirect/app/FolderMonitorService.kt new file mode 100644 index 0000000..d311a2a --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/FolderMonitorService.kt @@ -0,0 +1,277 @@ +package com.folderredirect.app + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import java.io.File +import kotlin.concurrent.thread + +class FolderMonitorService : Service() { + + private var isRunning = false + private var isScreenOn = true + private val rulesManager by lazy { RulesManager(this) } + private val activityLogManager by lazy { ActivityLogManager(this) } + private val workManagerScheduler by lazy { WorkManagerScheduler(this) } + private var pollingThread: Thread? = null + private val immichUploaders = mutableMapOf() + private var appInForeground = true + + private val screenReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + when (intent?.action) { + Intent.ACTION_SCREEN_ON -> { + isScreenOn = true + } + Intent.ACTION_SCREEN_OFF -> { + isScreenOn = false + } + } + } + } + + companion object { + private const val CHANNEL_ID = "folder_monitor_channel" + private const val NOTIFICATION_ID = 1001 + private const val TAG = "FolderMonitorService" + } + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + registerScreenReceiver() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + "START" -> startMonitoring() + "STOP" -> stopMonitoring() + "MOVE_EXISTING" -> moveAllExistingFiles() + "APP_FOREGROUND" -> setAppInForeground(true) + "APP_BACKGROUND" -> setAppInForeground(false) + "UPDATE_PROGRESS_NOTIFICATION" -> updateProgressNotification() + } + return START_STICKY + } + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onDestroy() { + stopMonitoring() + unregisterReceiver(screenReceiver) + super.onDestroy() + } + + private fun registerScreenReceiver() { + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(screenReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(screenReceiver, filter) + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Monitor de Carpetas", + NotificationManager.IMPORTANCE_LOW + ).apply { + description = "Servicio de monitoreo de carpetas" + setShowBadge(false) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + } + + private fun createNotification(progressText: String = "Monitoreo activo", progress: Int = -1): Notification { + val openIntent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val openPendingIntent = PendingIntent.getActivity( + this, 0, openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val stopIntent = Intent(this, FolderMonitorService::class.java).apply { + action = "STOP" + } + val stopPendingIntent = PendingIntent.getService( + this, 1, stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val builder = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Folder Redirect") + .setContentText(progressText) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentIntent(openPendingIntent) + .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Detener", stopPendingIntent) + + if (progress >= 0) { + builder.setProgress(100, progress, false) + } + + return builder.build() + } + + private fun startMonitoring() { + if (isRunning) return + + Log.d(TAG, "Iniciando servicio (solo WorkManager)...") + isRunning = true + startForeground(NOTIFICATION_ID, createNotification()) + + val rules = rulesManager.getAllRules().filter { it.isEnabled } + rules.forEach { rule -> + initializeRuleFolders(rule) + } + + val immichRules = rulesManager.getAllImmichRules().filter { it.isEnabled } + immichRules.forEach { rule -> + initializeImmichUploader(rule) + } + + Log.d(TAG, "Programando WorkManager para procesamiento en segundo plano") + workManagerScheduler.scheduleBackgroundWork() + } + + private fun initializeRuleFolders(rule: FolderRule) { + thread { + try { + val sourceDir = File(rule.sourcePath) + if (!sourceDir.exists() || !sourceDir.isDirectory) { + if (!sourceDir.mkdirs()) { + Log.e(TAG, "No se pudo crear la carpeta de origen: ${rule.sourcePath}") + return@thread + } + } + + rulesManager.updateFolderRuleProgress(rule.id, 100) + sendProgressUpdate() + } catch (e: Exception) { + Log.e(TAG, "Error en initializeRuleFolders: ${e.message}") + } + } + } + + private fun initializeImmichUploader(rule: ImmichUploadRule) { + thread { + try { + val sourceDir = File(rule.sourcePath) + if (!sourceDir.exists() || !sourceDir.isDirectory) { + if (!sourceDir.mkdirs()) { + Log.e(TAG, "No se pudo crear la carpeta de origen: ${rule.sourcePath}") + return@thread + } + } + + immichUploaders[rule.id] = ImmichUploader(rule.getNormalizedImmichUrl(), rule.apiKey) + rulesManager.updateImmichRuleProgress(rule.id, 100) + sendProgressUpdate() + } catch (e: Exception) { + Log.e(TAG, "Error en initializeImmichUploader: ${e.message}") + } + } + } + + private fun stopMonitoring() { + if (!isRunning) return + + Log.d(TAG, "Deteniendo servicio, WorkManager continuará en segundo plano") + isRunning = false + pollingThread?.interrupt() + immichUploaders.clear() + stopForeground(STOP_FOREGROUND_REMOVE) + } + + private fun moveAllExistingFiles() { + Log.d(TAG, "Forzando procesamiento de archivos existentes...") + workManagerScheduler.triggerImmediateWork() + } + + fun updateProgressNotification() { + if (!isRunning) return + + val folderRules = rulesManager.getAllRules() + val immichRules = rulesManager.getAllImmichRules() + + val folderActive = folderRules.filter { it.progress in 1..99 } + val immichActive = immichRules.filter { it.progress in 1..99 } + val activeCount = folderActive.size + immichActive.size + + val allComplete = folderRules.all { it.progress == 100 } && immichRules.all { it.progress == 100 } + val hasAnyRule = folderRules.isNotEmpty() || immichRules.isNotEmpty() + + if (activeCount == 0) { + if (allComplete && hasAnyRule && !appInForeground) { + Log.d(TAG, "Todas las reglas completadas, deteniendo servicio") + stopMonitoring() + rulesManager.setServiceRunning(false) + return + } + + val notification = createNotification("Monitoreo activo", -1) + val manager = getSystemService(NotificationManager::class.java) + manager.notify(NOTIFICATION_ID, notification) + } else { + val allProgresss = folderActive.map { it.progress } + immichActive.map { it.progress } + val avgProgress = if (allProgresss.isNotEmpty()) { + allProgresss.average().toInt() + } else { + 0 + } + + val allNames = folderActive.map { it.name.ifBlank { "Sin nombre" } } + immichActive.map { it.name.ifBlank { "Sin nombre" } } + val progressText = if (activeCount == 1) { + "${allNames.first()}: ${allProgresss.first()}%" + } else { + "Procesando $activeCount reglas: $avgProgress%" + } + + val notification = createNotification(progressText, avgProgress) + val manager = getSystemService(NotificationManager::class.java) + manager.notify(NOTIFICATION_ID, notification) + } + + sendProgressUpdate() + } + + fun setAppInForeground(inForeground: Boolean) { + appInForeground = inForeground + if (inForeground) { + stopForeground(STOP_FOREGROUND_DETACH) + } else { + updateProgressNotification() + } + } + + private fun sendProgressUpdate() { + try { + val globalIntent = Intent("com.folderredirect.app.PROGRESS_UPDATE") + sendBroadcast(globalIntent) + + val localIntent = Intent("com.folderredirect.app.PROGRESS_UPDATE_LOCAL") + androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).sendBroadcast(localIntent) + } catch (e: Exception) { + Log.e(TAG, "Error enviando broadcast: ${e.message}") + } + } +} diff --git a/app/src/main/java/com/folderredirect/app/FolderRule.kt b/app/src/main/java/com/folderredirect/app/FolderRule.kt new file mode 100644 index 0000000..c62c89e --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/FolderRule.kt @@ -0,0 +1,55 @@ +package com.folderredirect.app + +import java.io.Serializable + +enum class FileTypeCategory(val displayName: String, val extensions: List) { + ALL("Todos los archivos", emptyList()), + DOCUMENTS("Documentos", listOf("pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "odt")), + IMAGES("Imágenes", listOf("jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "heic")), + VIDEOS("Videos", listOf("mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v")) +} + +data class FolderRule( + val id: Long = System.currentTimeMillis(), + var name: String = "", + var description: String = "", + var sourcePath: String = "", + var destinationPath: String = "", + var isEnabled: Boolean = true, + var fileTypeCategory: String? = null, + var namePattern: String? = null, + var progress: Int = 0 +) : Serializable { + + @Transient + private var cachedCategory: FileTypeCategory? = null + + fun getCategory(): FileTypeCategory { + val categoryStr = fileTypeCategory + if (cachedCategory != null) return cachedCategory!! + + cachedCategory = if (categoryStr.isNullOrBlank()) { + FileTypeCategory.ALL + } else { + try { + FileTypeCategory.valueOf(categoryStr) + } catch (e: Exception) { + FileTypeCategory.ALL + } + } + return cachedCategory!! + } + + fun setCategory(category: FileTypeCategory) { + fileTypeCategory = category.name + cachedCategory = category + } + + fun getPattern(): String? { + return namePattern?.trim()?.ifBlank { null } + } + + fun setPattern(pattern: String) { + namePattern = pattern.trim() + } +} diff --git a/app/src/main/java/com/folderredirect/app/ImmichUploadRule.kt b/app/src/main/java/com/folderredirect/app/ImmichUploadRule.kt new file mode 100644 index 0000000..f5d0ea4 --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/ImmichUploadRule.kt @@ -0,0 +1,53 @@ +package com.folderredirect.app + +import java.io.Serializable + +data class ImmichUploadRule( + val id: Long = System.currentTimeMillis(), + var name: String = "", + var description: String = "", + var sourcePath: String = "", + var immichUrl: String = "", + var apiKey: String = "", + var isEnabled: Boolean = true, + var fileTypeCategory: String = FileTypeCategory.IMAGES.name, + var namePattern: String? = null, + var progress: Int = 0 +) : Serializable { + + @Transient + private var cachedCategory: FileTypeCategory? = null + + fun getCategory(): FileTypeCategory { + val categoryStr = fileTypeCategory + if (cachedCategory != null) return cachedCategory!! + + cachedCategory = if (categoryStr.isNullOrBlank()) { + FileTypeCategory.IMAGES + } else { + try { + FileTypeCategory.valueOf(categoryStr) + } catch (e: Exception) { + FileTypeCategory.IMAGES + } + } + return cachedCategory!! + } + + fun setCategory(category: FileTypeCategory) { + fileTypeCategory = category.name + cachedCategory = category + } + + fun getPattern(): String? { + return namePattern?.trim()?.ifBlank { null } + } + + fun setPattern(pattern: String) { + namePattern = pattern.trim() + } + + fun getNormalizedImmichUrl(): String { + return immichUrl.trimEnd('/') + } +} diff --git a/app/src/main/java/com/folderredirect/app/ImmichUploader.kt b/app/src/main/java/com/folderredirect/app/ImmichUploader.kt new file mode 100644 index 0000000..ef2773a --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/ImmichUploader.kt @@ -0,0 +1,229 @@ +package com.folderredirect.app + +import android.util.Log +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.util.concurrent.TimeUnit + +class ImmichUploader(private val baseUrl: String, private val apiKey: String) { + + companion object { + private const val TAG = "ImmichUploader" + private const val CONNECT_TIMEOUT = 60L + private const val READ_TIMEOUT = 120L + private const val WRITE_TIMEOUT = 120L + } + + private val client = OkHttpClient.Builder() + .connectTimeout(CONNECT_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT, TimeUnit.SECONDS) + .writeTimeout(WRITE_TIMEOUT, TimeUnit.SECONDS) + .build() + + private val albumCache = mutableMapOf() + + data class UploadResult( + val success: Boolean, + val assetId: String? = null, + val albumId: String? = null, + val error: String? = null + ) + + suspend fun uploadAsset(file: File, albumName: String? = null): UploadResult { + return try { + Log.d(TAG, "Subiendo archivo: ${file.name}, álbum: $albumName") + + val assetId = uploadAssetInternal(file) + if (assetId == null) { + return UploadResult(false, error = "Failed to get asset ID from response") + } + + var albumId: String? = null + if (!albumName.isNullOrBlank()) { + albumId = getOrCreateAlbum(albumName) + if (albumId != null) { + addAssetToAlbum(albumId, assetId) + } + } + + UploadResult(true, assetId = assetId, albumId = albumId) + } catch (e: Exception) { + Log.e(TAG, "Error subiendo archivo: ${e.message}", e) + UploadResult(false, error = e.message) + } + } + + private suspend fun uploadAssetInternal(file: File): String? { + val mediaType = getMediaType(file) + val requestFile = file.asRequestBody(mediaType.toMediaTypeOrNull()) + + val assetBody = MultipartBody.Part.createFormData( + "assetData", + file.name, + requestFile + ) + + val fileCreatedAt = java.util.Date(file.lastModified()) + val fileModifiedAt = java.util.Date(file.lastModified()) + val deviceAssetId = "${file.name}_${file.lastModified()}" + val deviceId = "FolderRedirectApp" + + val requestBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addPart(assetBody) + .addFormDataPart("deviceAssetId", deviceAssetId) + .addFormDataPart("deviceId", deviceId) + .addFormDataPart("fileCreatedAt", fileCreatedAt.toInstant().toString()) + .addFormDataPart("fileModifiedAt", fileModifiedAt.toInstant().toString()) + .build() + + val request = Request.Builder() + .url("$baseUrl/api/assets") + .addHeader("x-api-key", apiKey) + .post(requestBody) + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() + Log.d(TAG, "Response upload: ${response.code} - $responseBody") + + if (response.isSuccessful && !responseBody.isNullOrBlank()) { + try { + val json = JSONObject(responseBody) + return json.getString("id") + } catch (e: Exception) { + Log.e(TAG, "Error parsing response: ${e.message}") + return null + } + } else { + Log.e(TAG, "Error en upload: ${response.code} - $responseBody") + return null + } + } + } + + private suspend fun getOrCreateAlbum(albumName: String): String? { + albumCache[albumName]?.let { return it } + + return try { + val albumId = searchAlbum(albumName) ?: createAlbum(albumName) + if (albumId != null) { + albumCache[albumName] = albumId + } + albumId + } catch (e: Exception) { + Log.e(TAG, "Error getting/creating album: ${e.message}", e) + null + } + } + + private suspend fun searchAlbum(albumName: String): String? { + val request = Request.Builder() + .url("$baseUrl/api/albums?withPartners=true") + .addHeader("x-api-key", apiKey) + .get() + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return null + + val responseBody = response.body?.string() + if (responseBody.isNullOrBlank()) return null + + try { + val albums = JSONArray(responseBody) + + for (i in 0 until albums.length()) { + val album = albums.getJSONObject(i) + if (album.getString("albumName") == albumName) { + return album.getString("id") + } + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing albums response: ${e.message}", e) + return null + } + } + return null + } + + private suspend fun createAlbum(albumName: String): String? { + val jsonBody = JSONObject().apply { + put("albumName", albumName) + } + + val request = Request.Builder() + .url("$baseUrl/api/albums") + .addHeader("x-api-key", apiKey) + .addHeader("Content-Type", "application/json") + .post(jsonBody.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())) + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() + Log.d(TAG, "Response create album: ${response.code} - $responseBody") + + if (response.isSuccessful && !responseBody.isNullOrBlank()) { + try { + val json = JSONObject(responseBody) + return json.getString("id") + } catch (e: Exception) { + Log.e(TAG, "Error parsing album response: ${e.message}") + return null + } + } else { + Log.e(TAG, "Error creando álbum: ${response.code} - $responseBody") + return null + } + } + } + + private suspend fun addAssetToAlbum(albumId: String, assetId: String) { + val jsonBody = JSONObject().apply { + put("ids", JSONArray().put(assetId)) + } + + val request = Request.Builder() + .url("$baseUrl/api/albums/$albumId/assets") + .addHeader("x-api-key", apiKey) + .addHeader("Content-Type", "application/json") + .put(jsonBody.toString().toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())) + .build() + + client.newCall(request).execute().use { response -> + Log.d(TAG, "Add to album response: ${response.code}") + if (!response.isSuccessful) { + Log.e(TAG, "Error adding asset to album: ${response.body?.string()}") + } + } + } + + private fun getMediaType(file: File): String { + val extension = file.extension.lowercase() + return when (extension) { + "jpg", "jpeg" -> "image/jpeg" + "png" -> "image/png" + "gif" -> "image/gif" + "webp" -> "image/webp" + "heic" -> "image/heic" + "mp4" -> "video/mp4" + "mov" -> "video/quicktime" + "avi" -> "video/x-msvideo" + "mkv" -> "video/x-matroska" + "webm" -> "video/webm" + else -> "application/octet-stream" + } + } + + fun clearCache() { + albumCache.clear() + } +} diff --git a/app/src/main/java/com/folderredirect/app/MainActivity.kt b/app/src/main/java/com/folderredirect/app/MainActivity.kt new file mode 100644 index 0000000..88d6a54 --- /dev/null +++ b/app/src/main/java/com/folderredirect/app/MainActivity.kt @@ -0,0 +1,758 @@ +package com.folderredirect.app + +import android.Manifest +import android.content.Context +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 android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.TextView +import android.widget.Toast +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.button.MaterialButton +import com.google.android.material.color.DynamicColors +import com.google.android.material.floatingactionbutton.FloatingActionButton +import com.google.android.material.materialswitch.MaterialSwitch +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import android.os.PowerManager +import android.os.PowerManager.WakeLock +import androidx.viewpager2.widget.ViewPager2 + +class MainActivity : AppCompatActivity() { + + private lateinit var tabLayout: TabLayout + private lateinit var viewPager: ViewPager2 + private var wakeLock: WakeLock? = null + + internal val rulesList = mutableListOf() + internal val immichRulesList = mutableListOf() + + private var tempSourcePath: String = "" + private var tempDestinationPath: String = "" + private var currentDialog: AlertDialog? = null + private var editSource: EditText? = null + private var editDestination: EditText? = null + private var radioGroupFileType: RadioGroup? = null + private var editNamePattern: EditText? = null + private var editRuleName: EditText? = null + private var editRuleDescription: EditText? = null + private var editImmichUrl: EditText? = null + private var editApiKey: EditText? = null + + lateinit var rulesManager: RulesManager + lateinit var adapter: UnifiedRuleAdapter + + private val progressUpdateReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context, intent: android.content.Intent) { + updateProgressFromRules() + } + } + + private val localProgressReceiver = object : android.content.BroadcastReceiver() { + override fun onReceive(context: android.content.Context, intent: android.content.Intent) { + updateProgressFromRules() + } + } + + private fun updateProgressFromRules() { + val folderRules = rulesManager.getAllRules() + val immichRules = rulesManager.getAllImmichRules() + adapter.updateProgressOnly(folderRules, immichRules) + } + + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { permissions -> + val allGranted = permissions.all { it.value } + if (allGranted) { + Toast.makeText(this, "Permisos concedidos", Toast.LENGTH_SHORT).show() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestNotificationPermission() + } + } else { + Toast.makeText(this, "Permisos denegados", Toast.LENGTH_LONG).show() + } + } + + private val notificationPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + Toast.makeText(this, "Permiso de notificación concedido", Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this, "Permiso de notificación denegado", Toast.LENGTH_SHORT).show() + } + } + + private val sourceFolderLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri: Uri? -> + uri?.let { + tempSourcePath = getFullPathFromTreeUri(it) + (currentDialog?.findViewById(R.id.editSource))?.setText(tempSourcePath) + } + } + + private val destinationFolderLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree() + ) { uri: Uri? -> + uri?.let { + tempDestinationPath = getFullPathFromTreeUri(it) + (currentDialog?.findViewById(R.id.editDestination))?.setText(tempDestinationPath) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + DynamicColors.applyToActivitiesIfAvailable(application) + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + rulesManager = RulesManager(this) + + adapter = UnifiedRuleAdapter( + rulesManager = rulesManager, + onEditFolder = { rule -> showRuleDialog(rule) }, + onEditImmich = { rule -> showImmichRuleDialog(rule) }, + onDeleteFolder = { rule -> confirmDelete(rule) }, + onDeleteImmich = { rule -> confirmDeleteImmich(rule) }, + onToggleFolder = { rule -> + rulesManager.saveRule(rule) + restartService() + }, + onToggleImmich = { rule -> + rulesManager.saveImmichRule(rule) + restartService() + } + ) + + tabLayout = findViewById(R.id.tabLayout) + viewPager = findViewById(R.id.viewPager) + + setupViewPager() + loadRules() + checkPermissions() + updateServiceButton() + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + registerReceiver(progressUpdateReceiver, android.content.IntentFilter("com.folderredirect.app.PROGRESS_UPDATE"), Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(progressUpdateReceiver, android.content.IntentFilter("com.folderredirect.app.PROGRESS_UPDATE")) + } + + androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).registerReceiver( + localProgressReceiver, + android.content.IntentFilter("com.folderredirect.app.PROGRESS_UPDATE_LOCAL") + ) + + updateProgressFromRules() + + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK, + "FolderRedirectApp::MainActivity" + ) + wakeLock?.acquire(10*60*1000L) + } + + override fun onDestroy() { + super.onDestroy() + try { + unregisterReceiver(progressUpdateReceiver) + } catch (e: Exception) {} + + try { + androidx.localbroadcastmanager.content.LocalBroadcastManager.getInstance(this).unregisterReceiver(localProgressReceiver) + } catch (e: Exception) {} + + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + } + + override fun onResume() { + super.onResume() + loadRules() + updateServiceButton() + restartService() + notifyServiceAppForeground(true) + } + + override fun onPause() { + super.onPause() + notifyServiceAppForeground(false) + } + + override fun onStop() { + super.onStop() + notifyServiceAppForeground(false) + } + + private fun notifyServiceAppForeground(inForeground: Boolean) { + if (rulesManager.isServiceRunning()) { + val intent = Intent(this, FolderMonitorService::class.java) + intent.action = if (inForeground) "APP_FOREGROUND" else "APP_BACKGROUND" + startService(intent) + } + } + + private fun setupViewPager() { + val fragments = listOf( + RulesFragment(), + SettingsFragment() + ) + + val pagerAdapter = ViewPagerAdapter(this, fragments) + viewPager.adapter = pagerAdapter + + TabLayoutMediator(tabLayout, viewPager) { tab, position -> + tab.text = when (position) { + 0 -> "Reglas" + 1 -> "Configuración" + else -> "" + } + }.attach() + } + + private fun loadRules() { + rulesList.clear() + rulesList.addAll(rulesManager.getAllRules()) + + immichRulesList.clear() + immichRulesList.addAll(rulesManager.getAllImmichRules()) + + adapter.updateRules(rulesList, immichRulesList) + + val rulesFragment = supportFragmentManager.findFragmentByTag("f0") as? RulesFragment + rulesFragment?.updateEmptyView() + } + + private fun checkPermissions() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + if (!Environment.isExternalStorageManager()) { + val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) + intent.data = Uri.parse("package:$packageName") + startActivity(intent) + } + } else { + val readPermission = Manifest.permission.READ_EXTERNAL_STORAGE + val writePermission = Manifest.permission.WRITE_EXTERNAL_STORAGE + + val permissions = arrayOf(readPermission, writePermission) + + if (ContextCompat.checkSelfPermission(this, readPermission) + != PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(this, writePermission) + != PackageManager.PERMISSION_GRANTED) { + permissionLauncher.launch(permissions) + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + requestNotificationPermission() + } + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + + internal fun showRuleDialog(rule: FolderRule?) { + tempSourcePath = rule?.sourcePath ?: "" + tempDestinationPath = rule?.destinationPath ?: "" + val currentCategory = rule?.getCategory() ?: FileTypeCategory.ALL + val currentPattern = rule?.getPattern() ?: "" + + val view = layoutInflater.inflate(R.layout.dialog_rule, null) + editRuleName = view.findViewById(R.id.editRuleName) + editRuleDescription = view.findViewById(R.id.editRuleDescription) + editSource = view.findViewById(R.id.editSource) + editDestination = view.findViewById(R.id.editDestination) + radioGroupFileType = view.findViewById(R.id.radioGroupFileType) + editNamePattern = view.findViewById(R.id.editNamePattern) + val btnSelectSource = view.findViewById