Initial commit - Folder Redirect App
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-dontwarn org.jetbrains.kotlin.**
|
||||||
|
-keep class com.folderredirect.app.** { *; }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
|
||||||
|
tools:ignore="ScopedStorage" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.FolderRedirectApp"
|
||||||
|
android:requestLegacyExternalStorage="true">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".FolderMonitorService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".ExecuteTasksService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ExecuteTasksWidgetProvider"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
<action android:name="com.folderredirect.app.ACTION_EXECUTE_TASKS" />
|
||||||
|
<action android:name="com.folderredirect.app.ACTION_TASKS_COMPLETE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/widget_execute_tasks_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ActivityLogEntry> {
|
||||||
|
val json = prefs.getString(KEY_LOGS, null)
|
||||||
|
return if (json != null) {
|
||||||
|
val type = object : TypeToken<List<ActivityLogEntry>>() {}.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<ActivityLogEntry>) {
|
||||||
|
val json = gson.toJson(logs)
|
||||||
|
prefs.edit().putString(KEY_LOGS, json).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long, ImmichUploader>()
|
||||||
|
|
||||||
|
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<File> {
|
||||||
|
val files = mutableListOf<File>()
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<File> =
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<File> {
|
||||||
|
val files = mutableListOf<File>()
|
||||||
|
dir.listFiles()?.forEach { file ->
|
||||||
|
if (file.isFile) {
|
||||||
|
files.add(file)
|
||||||
|
} else if (file.isDirectory) {
|
||||||
|
files.addAll(getAllFilesRecursively(file))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Long, ImmichUploader>()
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.folderredirect.app
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
|
||||||
|
enum class FileTypeCategory(val displayName: String, val extensions: List<String>) {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, String>()
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FolderRule>()
|
||||||
|
internal val immichRulesList = mutableListOf<ImmichUploadRule>()
|
||||||
|
|
||||||
|
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<EditText>(R.id.editSource))?.setText(tempSourcePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val destinationFolderLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.OpenDocumentTree()
|
||||||
|
) { uri: Uri? ->
|
||||||
|
uri?.let {
|
||||||
|
tempDestinationPath = getFullPathFromTreeUri(it)
|
||||||
|
(currentDialog?.findViewById<EditText>(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<Button>(R.id.btnSelectSource)
|
||||||
|
val btnSelectDestination = view.findViewById<Button>(R.id.btnSelectDestination)
|
||||||
|
|
||||||
|
editRuleName?.setText(rule?.name ?: "")
|
||||||
|
editRuleDescription?.setText(rule?.description ?: "")
|
||||||
|
editSource?.setText(tempSourcePath)
|
||||||
|
editDestination?.setText(tempDestinationPath)
|
||||||
|
editNamePattern?.setText(currentPattern)
|
||||||
|
|
||||||
|
when (currentCategory) {
|
||||||
|
FileTypeCategory.ALL -> view.findViewById<RadioButton>(R.id.radioAll).isChecked = true
|
||||||
|
FileTypeCategory.DOCUMENTS -> view.findViewById<RadioButton>(R.id.radioDocuments).isChecked = true
|
||||||
|
FileTypeCategory.IMAGES -> view.findViewById<RadioButton>(R.id.radioImages).isChecked = true
|
||||||
|
FileTypeCategory.VIDEOS -> view.findViewById<RadioButton>(R.id.radioVideos).isChecked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSelectSource.setOnClickListener {
|
||||||
|
sourceFolderLauncher.launch(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSelectDestination.setOnClickListener {
|
||||||
|
destinationFolderLauncher.launch(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDialog = AlertDialog.Builder(this)
|
||||||
|
.setTitle(if (rule == null) "Nueva Regla" else "Editar Regla")
|
||||||
|
.setView(view)
|
||||||
|
.setPositiveButton("Guardar") { _, _ ->
|
||||||
|
val ruleName = editRuleName?.text.toString().trim()
|
||||||
|
val ruleDescription = editRuleDescription?.text.toString().trim()
|
||||||
|
val sourcePath = editSource?.text.toString().trim()
|
||||||
|
val destinationPath = editDestination?.text.toString().trim()
|
||||||
|
val namePattern = editNamePattern?.text.toString().trim()
|
||||||
|
|
||||||
|
val selectedCategoryId = radioGroupFileType?.checkedRadioButtonId
|
||||||
|
val fileTypeCategory = when (selectedCategoryId) {
|
||||||
|
R.id.radioAll -> FileTypeCategory.ALL
|
||||||
|
R.id.radioDocuments -> FileTypeCategory.DOCUMENTS
|
||||||
|
R.id.radioImages -> FileTypeCategory.IMAGES
|
||||||
|
R.id.radioVideos -> FileTypeCategory.VIDEOS
|
||||||
|
else -> FileTypeCategory.ALL
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourcePath.isEmpty() || destinationPath.isEmpty()) {
|
||||||
|
Toast.makeText(this, "Complete todos los campos", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setPositiveButton
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPath(sourcePath)) {
|
||||||
|
Toast.makeText(this, "Carpeta de origen no válida", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setPositiveButton
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule == null) {
|
||||||
|
val newRule = FolderRule(
|
||||||
|
name = ruleName,
|
||||||
|
description = ruleDescription,
|
||||||
|
sourcePath = sourcePath,
|
||||||
|
destinationPath = destinationPath
|
||||||
|
)
|
||||||
|
newRule.setCategory(fileTypeCategory)
|
||||||
|
if (namePattern.isNotEmpty()) {
|
||||||
|
newRule.setPattern(namePattern)
|
||||||
|
}
|
||||||
|
rulesManager.saveRule(newRule)
|
||||||
|
} else {
|
||||||
|
rule.name = ruleName
|
||||||
|
rule.description = ruleDescription
|
||||||
|
rule.sourcePath = sourcePath
|
||||||
|
rule.destinationPath = destinationPath
|
||||||
|
rule.setCategory(fileTypeCategory)
|
||||||
|
if (namePattern.isNotEmpty()) {
|
||||||
|
rule.setPattern(namePattern)
|
||||||
|
} else {
|
||||||
|
rule.namePattern = null
|
||||||
|
}
|
||||||
|
rulesManager.saveRule(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRules()
|
||||||
|
restartService()
|
||||||
|
|
||||||
|
Toast.makeText(this, "Regla guardada", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancelar", null)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
currentDialog?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun showImmichRuleDialog(rule: ImmichUploadRule?) {
|
||||||
|
tempSourcePath = rule?.sourcePath ?: ""
|
||||||
|
val currentCategory = rule?.getCategory() ?: FileTypeCategory.IMAGES
|
||||||
|
val currentPattern = rule?.getPattern() ?: ""
|
||||||
|
|
||||||
|
val view = layoutInflater.inflate(R.layout.dialog_immich_rule, null)
|
||||||
|
editRuleName = view.findViewById(R.id.editRuleName)
|
||||||
|
editRuleDescription = view.findViewById(R.id.editRuleDescription)
|
||||||
|
editSource = view.findViewById(R.id.editSource)
|
||||||
|
editImmichUrl = view.findViewById(R.id.editImmichUrl)
|
||||||
|
editApiKey = view.findViewById(R.id.editApiKey)
|
||||||
|
radioGroupFileType = view.findViewById(R.id.radioGroupFileType)
|
||||||
|
editNamePattern = view.findViewById(R.id.editNamePattern)
|
||||||
|
val btnSelectSource = view.findViewById<Button>(R.id.btnSelectSource)
|
||||||
|
|
||||||
|
editRuleName?.setText(rule?.name ?: "")
|
||||||
|
editRuleDescription?.setText(rule?.description ?: "")
|
||||||
|
editSource?.setText(tempSourcePath)
|
||||||
|
editImmichUrl?.setText(rule?.immichUrl ?: "")
|
||||||
|
editApiKey?.setText(rule?.apiKey ?: "")
|
||||||
|
editNamePattern?.setText(currentPattern)
|
||||||
|
|
||||||
|
when (currentCategory) {
|
||||||
|
FileTypeCategory.IMAGES -> view.findViewById<RadioButton>(R.id.radioImages).isChecked = true
|
||||||
|
FileTypeCategory.VIDEOS -> view.findViewById<RadioButton>(R.id.radioVideos).isChecked = true
|
||||||
|
else -> view.findViewById<RadioButton>(R.id.radioAll).isChecked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSelectSource.setOnClickListener {
|
||||||
|
sourceFolderLauncher.launch(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDialog = AlertDialog.Builder(this)
|
||||||
|
.setTitle(if (rule == null) "Nueva Regla Immich" else "Editar Regla Immich")
|
||||||
|
.setView(view)
|
||||||
|
.setPositiveButton("Guardar") { _, _ ->
|
||||||
|
val ruleName = editRuleName?.text.toString().trim()
|
||||||
|
val ruleDescription = editRuleDescription?.text.toString().trim()
|
||||||
|
val sourcePath = editSource?.text.toString().trim()
|
||||||
|
val immichUrl = editImmichUrl?.text.toString().trim()
|
||||||
|
val apiKey = editApiKey?.text.toString().trim()
|
||||||
|
val namePattern = editNamePattern?.text.toString().trim()
|
||||||
|
|
||||||
|
val selectedCategoryId = radioGroupFileType?.checkedRadioButtonId
|
||||||
|
val fileTypeCategory = when (selectedCategoryId) {
|
||||||
|
R.id.radioImages -> FileTypeCategory.IMAGES
|
||||||
|
R.id.radioVideos -> FileTypeCategory.VIDEOS
|
||||||
|
R.id.radioAll -> FileTypeCategory.ALL
|
||||||
|
else -> FileTypeCategory.IMAGES
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourcePath.isEmpty() || immichUrl.isEmpty() || apiKey.isEmpty()) {
|
||||||
|
Toast.makeText(this, "Complete todos los campos", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setPositiveButton
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidPath(sourcePath)) {
|
||||||
|
Toast.makeText(this, "Carpeta de origen no válida", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setPositiveButton
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!immichUrl.startsWith("http://") && !immichUrl.startsWith("https://")) {
|
||||||
|
Toast.makeText(this, "URL debe comenzar con http:// o https://", Toast.LENGTH_SHORT).show()
|
||||||
|
return@setPositiveButton
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule == null) {
|
||||||
|
val newRule = ImmichUploadRule(
|
||||||
|
name = ruleName,
|
||||||
|
description = ruleDescription,
|
||||||
|
sourcePath = sourcePath,
|
||||||
|
immichUrl = immichUrl,
|
||||||
|
apiKey = apiKey
|
||||||
|
)
|
||||||
|
newRule.setCategory(fileTypeCategory)
|
||||||
|
if (namePattern.isNotEmpty()) {
|
||||||
|
newRule.setPattern(namePattern)
|
||||||
|
}
|
||||||
|
rulesManager.saveImmichRule(newRule)
|
||||||
|
} else {
|
||||||
|
rule.name = ruleName
|
||||||
|
rule.description = ruleDescription
|
||||||
|
rule.sourcePath = sourcePath
|
||||||
|
rule.immichUrl = immichUrl
|
||||||
|
rule.apiKey = apiKey
|
||||||
|
rule.setCategory(fileTypeCategory)
|
||||||
|
if (namePattern.isNotEmpty()) {
|
||||||
|
rule.setPattern(namePattern)
|
||||||
|
} else {
|
||||||
|
rule.namePattern = null
|
||||||
|
}
|
||||||
|
rulesManager.saveImmichRule(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadRules()
|
||||||
|
restartService()
|
||||||
|
|
||||||
|
Toast.makeText(this, "Regla Immich guardada", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancelar", null)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
currentDialog?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun confirmDeleteImmich(rule: ImmichUploadRule) {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Eliminar Regla Immich")
|
||||||
|
.setMessage("¿Está seguro de eliminar esta regla?")
|
||||||
|
.setPositiveButton("Eliminar") { _, _ ->
|
||||||
|
rulesManager.deleteImmichRule(rule)
|
||||||
|
loadRules()
|
||||||
|
restartService()
|
||||||
|
Toast.makeText(this, "Regla eliminada", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancelar", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun showLogViewerDialog() {
|
||||||
|
val logManager = ActivityLogManager(this)
|
||||||
|
val logs = logManager.getLogs()
|
||||||
|
|
||||||
|
val view = layoutInflater.inflate(R.layout.dialog_log_viewer, null)
|
||||||
|
val textLogs = view.findViewById<TextView>(R.id.textLogs)
|
||||||
|
val textLogEmpty = view.findViewById<TextView>(R.id.textLogEmpty)
|
||||||
|
val btnClearLogs = view.findViewById<MaterialButton>(R.id.btnClearLogs)
|
||||||
|
|
||||||
|
if (logs.isEmpty()) {
|
||||||
|
textLogEmpty.visibility = View.VISIBLE
|
||||||
|
textLogs.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
textLogEmpty.visibility = View.GONE
|
||||||
|
textLogs.visibility = View.VISIBLE
|
||||||
|
val sb = StringBuilder()
|
||||||
|
logs.reversed().forEach { entry ->
|
||||||
|
val typePrefix = when (entry.type) {
|
||||||
|
"MOVE" -> "[MOV]"
|
||||||
|
"UPLOAD" -> "[SUB]"
|
||||||
|
else -> "[ERR]"
|
||||||
|
}
|
||||||
|
sb.append("$typePrefix [${entry.formattedTime()}] ${entry.message}\n")
|
||||||
|
}
|
||||||
|
textLogs.text = sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = AlertDialog.Builder(this)
|
||||||
|
.setView(view)
|
||||||
|
.setPositiveButton("Cerrar", null)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
btnClearLogs.setOnClickListener {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Limpiar registro")
|
||||||
|
.setMessage("¿Eliminar todo el registro de actividad?")
|
||||||
|
.setPositiveButton("Limpiar") { _, _ ->
|
||||||
|
logManager.clearLogs()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancelar", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getFullPathFromTreeUri(treeUri: Uri): String {
|
||||||
|
val path = treeUri.path ?: return ""
|
||||||
|
val split = path.split(":")
|
||||||
|
return if (split.size >= 2) {
|
||||||
|
"/storage/emulated/0/${split[1].trimEnd('/')}"
|
||||||
|
} else {
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidPath(path: String): Boolean {
|
||||||
|
return path.isNotBlank() && path.startsWith("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun confirmDelete(rule: FolderRule) {
|
||||||
|
AlertDialog.Builder(this)
|
||||||
|
.setTitle("Eliminar Regla")
|
||||||
|
.setMessage("¿Está seguro de eliminar esta regla?")
|
||||||
|
.setPositiveButton("Eliminar") { _, _ ->
|
||||||
|
rulesManager.deleteRule(rule)
|
||||||
|
loadRules()
|
||||||
|
restartService()
|
||||||
|
Toast.makeText(this, "Regla eliminada", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
.setNegativeButton("Cancelar", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun startServiceIfNeeded() {
|
||||||
|
if (rulesList.isEmpty() && immichRulesList.isEmpty()) {
|
||||||
|
Toast.makeText(this, "Agregue al menos una regla", Toast.LENGTH_SHORT).show()
|
||||||
|
val settingsFragment = supportFragmentManager.findFragmentByTag("f1") as? SettingsFragment
|
||||||
|
settingsFragment?.setSwitchChecked(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = Intent(this, FolderMonitorService::class.java)
|
||||||
|
intent.action = "START"
|
||||||
|
startService(intent)
|
||||||
|
rulesManager.setServiceRunning(true)
|
||||||
|
updateServiceButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun stopServiceIfNeeded() {
|
||||||
|
val intent = Intent(this, FolderMonitorService::class.java)
|
||||||
|
intent.action = "STOP"
|
||||||
|
startService(intent)
|
||||||
|
rulesManager.setServiceRunning(false)
|
||||||
|
updateServiceButton()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun restartService() {
|
||||||
|
if (rulesManager.isServiceRunning()) {
|
||||||
|
val intent = Intent(this, FolderMonitorService::class.java)
|
||||||
|
intent.action = "START"
|
||||||
|
startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateServiceButton() {
|
||||||
|
val isRunning = rulesManager.isServiceRunning()
|
||||||
|
val settingsFragment = supportFragmentManager.findFragmentByTag("f1") as? SettingsFragment
|
||||||
|
settingsFragment?.updateServiceStatus(isRunning, rulesList.isNotEmpty() || immichRulesList.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun moveExistingFiles() {
|
||||||
|
if (!rulesManager.isServiceRunning()) {
|
||||||
|
Toast.makeText(this, "Inicia el servicio primero", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(this, FolderMonitorService::class.java)
|
||||||
|
intent.action = "MOVE_EXISTING"
|
||||||
|
startService(intent)
|
||||||
|
Toast.makeText(this, "Moviendo archivos existentes...", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewPagerAdapter(
|
||||||
|
activity: AppCompatActivity,
|
||||||
|
private val fragments: List<Fragment>
|
||||||
|
) : androidx.viewpager2.adapter.FragmentStateAdapter(activity) {
|
||||||
|
override fun getItemCount() = fragments.size
|
||||||
|
override fun createFragment(position: Int) = fragments[position]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RulesFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var recyclerView: RecyclerView
|
||||||
|
private lateinit var emptyView: TextView
|
||||||
|
private lateinit var btnAdd: FloatingActionButton
|
||||||
|
private lateinit var btnAddImmich: FloatingActionButton
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.tab_rules, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
recyclerView = view.findViewById(R.id.recyclerView)
|
||||||
|
emptyView = view.findViewById(R.id.emptyView)
|
||||||
|
|
||||||
|
val mainActivity = requireActivity() as MainActivity
|
||||||
|
|
||||||
|
btnAdd = mainActivity.findViewById(R.id.btnAdd)
|
||||||
|
btnAddImmich = mainActivity.findViewById(R.id.btnAddImmich)
|
||||||
|
|
||||||
|
btnAdd.setOnClickListener {
|
||||||
|
mainActivity.showRuleDialog(null)
|
||||||
|
}
|
||||||
|
btnAddImmich.setOnClickListener {
|
||||||
|
mainActivity.showImmichRuleDialog(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
recyclerView.adapter = mainActivity.adapter
|
||||||
|
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateEmptyView() {
|
||||||
|
if (!::emptyView.isInitialized || !::recyclerView.isInitialized) return
|
||||||
|
val hasRules = mainActivity.rulesList.isNotEmpty() || mainActivity.immichRulesList.isNotEmpty()
|
||||||
|
emptyView.visibility = if (hasRules) View.GONE else View.VISIBLE
|
||||||
|
recyclerView.visibility = if (hasRules) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mainActivity: MainActivity
|
||||||
|
get() = requireActivity() as MainActivity
|
||||||
|
}
|
||||||
|
|
||||||
|
class SettingsFragment : Fragment() {
|
||||||
|
|
||||||
|
private lateinit var switchService: MaterialSwitch
|
||||||
|
private lateinit var textServiceStatus: TextView
|
||||||
|
private lateinit var btnMoveExisting: Button
|
||||||
|
private lateinit var cardMoveExisting: View
|
||||||
|
private lateinit var btnViewLogs: MaterialButton
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View? {
|
||||||
|
return inflater.inflate(R.layout.tab_settings, container, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
|
||||||
|
switchService = view.findViewById(R.id.switchService)
|
||||||
|
textServiceStatus = view.findViewById(R.id.textServiceStatus)
|
||||||
|
btnMoveExisting = view.findViewById(R.id.btnMoveExisting)
|
||||||
|
cardMoveExisting = view.findViewById(R.id.cardMoveExisting)
|
||||||
|
btnViewLogs = view.findViewById(R.id.btnViewLogs)
|
||||||
|
|
||||||
|
val mainActivity = requireActivity() as MainActivity
|
||||||
|
val isRunning = mainActivity.rulesManager.isServiceRunning()
|
||||||
|
val hasRules = mainActivity.rulesList.isNotEmpty() || mainActivity.immichRulesList.isNotEmpty()
|
||||||
|
|
||||||
|
updateServiceStatus(isRunning, hasRules)
|
||||||
|
|
||||||
|
switchService.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
if (isChecked) {
|
||||||
|
mainActivity.startServiceIfNeeded()
|
||||||
|
} else {
|
||||||
|
mainActivity.stopServiceIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnMoveExisting.setOnClickListener {
|
||||||
|
mainActivity.moveExistingFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
btnViewLogs.setOnClickListener {
|
||||||
|
mainActivity.showLogViewerDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateServiceStatus(isRunning: Boolean, hasRules: Boolean) {
|
||||||
|
if (::textServiceStatus.isInitialized) {
|
||||||
|
textServiceStatus.text = if (isRunning) "Ejecutándose" else "Detenido"
|
||||||
|
textServiceStatus.setTextColor(
|
||||||
|
ContextCompat.getColor(requireContext(), if (isRunning) android.R.color.holo_green_light else android.R.color.holo_red_light)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (::switchService.isInitialized) {
|
||||||
|
switchService.isChecked = isRunning
|
||||||
|
}
|
||||||
|
if (::cardMoveExisting.isInitialized) {
|
||||||
|
cardMoveExisting.visibility = if (isRunning && hasRules) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSwitchChecked(checked: Boolean) {
|
||||||
|
if (::switchService.isInitialized) {
|
||||||
|
switchService.isChecked = checked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package com.folderredirect.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
class RulesManager(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "folder_rules_prefs"
|
||||||
|
private const val KEY_RULES = "folder_rules"
|
||||||
|
private const val KEY_IMMICH_RULES = "immich_rules"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllRules(): List<FolderRule> {
|
||||||
|
val json = prefs.getString(KEY_RULES, null)
|
||||||
|
return if (json != null) {
|
||||||
|
val type = object : TypeToken<List<FolderRule>>() {}.type
|
||||||
|
gson.fromJson(json, type)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveRule(rule: FolderRule) {
|
||||||
|
val rules = getAllRules().toMutableList()
|
||||||
|
val existingIndex = rules.indexOfFirst { it.id == rule.id }
|
||||||
|
|
||||||
|
if (existingIndex != -1) {
|
||||||
|
rules[existingIndex] = rule
|
||||||
|
} else {
|
||||||
|
rules.add(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAllRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteRule(rule: FolderRule) {
|
||||||
|
val rules = getAllRules().toMutableList()
|
||||||
|
rules.removeAll { it.id == rule.id }
|
||||||
|
saveAllRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAllRules(rules: List<FolderRule>) {
|
||||||
|
val json = gson.toJson(rules)
|
||||||
|
prefs.edit().putString(KEY_RULES, json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllImmichRules(): List<ImmichUploadRule> {
|
||||||
|
val json = prefs.getString(KEY_IMMICH_RULES, null)
|
||||||
|
return if (json != null) {
|
||||||
|
val type = object : TypeToken<List<ImmichUploadRule>>() {}.type
|
||||||
|
gson.fromJson(json, type)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveImmichRule(rule: ImmichUploadRule) {
|
||||||
|
val rules = getAllImmichRules().toMutableList()
|
||||||
|
val existingIndex = rules.indexOfFirst { it.id == rule.id }
|
||||||
|
|
||||||
|
if (existingIndex != -1) {
|
||||||
|
rules[existingIndex] = rule
|
||||||
|
} else {
|
||||||
|
rules.add(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
saveAllImmichRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteImmichRule(rule: ImmichUploadRule) {
|
||||||
|
val rules = getAllImmichRules().toMutableList()
|
||||||
|
rules.removeAll { it.id == rule.id }
|
||||||
|
saveAllImmichRules(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveAllImmichRules(rules: List<ImmichUploadRule>) {
|
||||||
|
val json = gson.toJson(rules)
|
||||||
|
prefs.edit().putString(KEY_IMMICH_RULES, json).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isServiceRunning(): Boolean {
|
||||||
|
return prefs.getBoolean("service_running", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setServiceRunning(running: Boolean) {
|
||||||
|
prefs.edit().putBoolean("service_running", running).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFolderRuleProgress(ruleId: Long, progress: Int) {
|
||||||
|
val rules = getAllRules().toMutableList()
|
||||||
|
val ruleIndex = rules.indexOfFirst { it.id == ruleId }
|
||||||
|
if (ruleIndex != -1) {
|
||||||
|
val rule = rules[ruleIndex]
|
||||||
|
rule.progress = progress
|
||||||
|
rules[ruleIndex] = rule
|
||||||
|
saveAllRules(rules)
|
||||||
|
android.util.Log.d("RulesManager", "Progreso actualizado para regla $ruleId: $progress%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateImmichRuleProgress(ruleId: Long, progress: Int) {
|
||||||
|
val rules = getAllImmichRules().toMutableList()
|
||||||
|
val ruleIndex = rules.indexOfFirst { it.id == ruleId }
|
||||||
|
if (ruleIndex != -1) {
|
||||||
|
val rule = rules[ruleIndex]
|
||||||
|
rule.progress = progress
|
||||||
|
rules[ruleIndex] = rule
|
||||||
|
saveAllImmichRules(rules)
|
||||||
|
android.util.Log.d("RulesManager", "Progreso Immich actualizado para regla $ruleId: $progress%")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package com.folderredirect.app
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
|
import com.google.android.material.chip.Chip
|
||||||
|
import com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
|
||||||
|
sealed class RuleListItem {
|
||||||
|
data class Folder(val rule: FolderRule) : RuleListItem()
|
||||||
|
data class Immich(val rule: ImmichUploadRule) : RuleListItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnifiedRuleAdapter(
|
||||||
|
private val rulesManager: RulesManager,
|
||||||
|
private val items: MutableList<RuleListItem> = mutableListOf(),
|
||||||
|
private val onEditFolder: (FolderRule) -> Unit,
|
||||||
|
private val onEditImmich: (ImmichUploadRule) -> Unit,
|
||||||
|
private val onDeleteFolder: (FolderRule) -> Unit,
|
||||||
|
private val onDeleteImmich: (ImmichUploadRule) -> Unit,
|
||||||
|
private val onToggleFolder: (FolderRule) -> Unit,
|
||||||
|
private val onToggleImmich: (ImmichUploadRule) -> Unit
|
||||||
|
) : RecyclerView.Adapter<UnifiedRuleAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private val expandedPositions = mutableSetOf<Int>()
|
||||||
|
private val viewHolderMap = mutableMapOf<Long, ViewHolder>()
|
||||||
|
|
||||||
|
inner class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||||
|
val textRuleName: TextView = itemView.findViewById(R.id.textRuleName)
|
||||||
|
val textRuleDescription: TextView = itemView.findViewById(R.id.textRuleDescription)
|
||||||
|
val textSource: TextView = itemView.findViewById(R.id.textSource)
|
||||||
|
val textDestination: TextView? = itemView.findViewById(R.id.textDestination)
|
||||||
|
val textImmichUrl: TextView? = itemView.findViewById(R.id.textImmichUrlValue)
|
||||||
|
val textImmichUrlLabel: TextView? = itemView.findViewById(R.id.textImmichUrl)
|
||||||
|
val chipFileType: Chip = itemView.findViewById(R.id.chipFileType)
|
||||||
|
val chipNamePattern: Chip = itemView.findViewById(R.id.chipNamePattern)
|
||||||
|
val switchEnabled: MaterialSwitch = itemView.findViewById(R.id.switchEnabled)
|
||||||
|
val btnEdit: MaterialButton = itemView.findViewById(R.id.btnEdit)
|
||||||
|
val btnDelete: MaterialButton = itemView.findViewById(R.id.btnDelete)
|
||||||
|
val btnExpand: MaterialButton = itemView.findViewById(R.id.btnExpand)
|
||||||
|
val layoutDetails: View = itemView.findViewById(R.id.layoutDetails)
|
||||||
|
val progressBar: LinearProgressIndicator = itemView.findViewById(R.id.progressBar)
|
||||||
|
val textProgressPercent: TextView = itemView.findViewById(R.id.textProgressPercent)
|
||||||
|
val rootCard: View = itemView
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val view = LayoutInflater.from(parent.context)
|
||||||
|
.inflate(R.layout.item_folder_rule, parent, false)
|
||||||
|
return ViewHolder(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val item = items[position]
|
||||||
|
val isExpanded = expandedPositions.contains(position)
|
||||||
|
|
||||||
|
when (item) {
|
||||||
|
is RuleListItem.Folder -> {
|
||||||
|
val rule = item.rule
|
||||||
|
viewHolderMap[rule.id] = holder
|
||||||
|
|
||||||
|
holder.textRuleName.text = rule.name.ifBlank { "Sin nombre" }
|
||||||
|
|
||||||
|
if (rule.description.isNotBlank()) {
|
||||||
|
holder.textRuleDescription.text = rule.description
|
||||||
|
holder.textRuleDescription.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
holder.textRuleDescription.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.textSource.text = rule.sourcePath
|
||||||
|
holder.textDestination?.text = rule.destinationPath
|
||||||
|
holder.textDestination?.visibility = View.VISIBLE
|
||||||
|
holder.textImmichUrl?.visibility = View.GONE
|
||||||
|
holder.textImmichUrlLabel?.visibility = View.GONE
|
||||||
|
|
||||||
|
setupFileTypeChip(holder.chipFileType, rule.getCategory())
|
||||||
|
|
||||||
|
val pattern = rule.getPattern()
|
||||||
|
if (!pattern.isNullOrBlank()) {
|
||||||
|
holder.chipNamePattern.text = pattern
|
||||||
|
holder.chipNamePattern.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
holder.chipNamePattern.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.switchEnabled.setOnCheckedChangeListener(null)
|
||||||
|
holder.switchEnabled.tag = rule.id
|
||||||
|
holder.switchEnabled.isChecked = rule.isEnabled
|
||||||
|
holder.switchEnabled.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||||
|
if (buttonView.tag == rule.id) {
|
||||||
|
rule.isEnabled = isChecked
|
||||||
|
onToggleFolder(rule)
|
||||||
|
holder.rootCard.post {
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCardEnabledState(holder, rule.isEnabled)
|
||||||
|
setExpandedState(holder, isExpanded)
|
||||||
|
|
||||||
|
holder.progressBar.setProgress(rule.progress, true)
|
||||||
|
holder.textProgressPercent.text = "${rule.progress}%"
|
||||||
|
|
||||||
|
holder.btnExpand.setOnClickListener {
|
||||||
|
toggleExpand(position)
|
||||||
|
}
|
||||||
|
holder.btnEdit.setOnClickListener { onEditFolder(rule) }
|
||||||
|
holder.btnDelete.setOnClickListener { onDeleteFolder(rule) }
|
||||||
|
}
|
||||||
|
is RuleListItem.Immich -> {
|
||||||
|
val rule = item.rule
|
||||||
|
viewHolderMap[rule.id] = holder
|
||||||
|
|
||||||
|
holder.textRuleName.text = rule.name.ifBlank { "Sin nombre" }
|
||||||
|
|
||||||
|
if (rule.description.isNotBlank()) {
|
||||||
|
holder.textRuleDescription.text = rule.description
|
||||||
|
holder.textRuleDescription.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
holder.textRuleDescription.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.textSource.text = rule.sourcePath
|
||||||
|
holder.textImmichUrl?.text = rule.immichUrl
|
||||||
|
holder.textDestination?.visibility = View.GONE
|
||||||
|
holder.textImmichUrl?.visibility = View.VISIBLE
|
||||||
|
holder.textImmichUrlLabel?.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
setupFileTypeChip(holder.chipFileType, rule.getCategory())
|
||||||
|
|
||||||
|
val pattern = rule.getPattern()
|
||||||
|
if (!pattern.isNullOrBlank()) {
|
||||||
|
holder.chipNamePattern.text = pattern
|
||||||
|
holder.chipNamePattern.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
holder.chipNamePattern.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.switchEnabled.setOnCheckedChangeListener(null)
|
||||||
|
holder.switchEnabled.tag = rule.id
|
||||||
|
holder.switchEnabled.isChecked = rule.isEnabled
|
||||||
|
holder.switchEnabled.setOnCheckedChangeListener { buttonView, isChecked ->
|
||||||
|
if (buttonView.tag == rule.id) {
|
||||||
|
rule.isEnabled = isChecked
|
||||||
|
onToggleImmich(rule)
|
||||||
|
holder.rootCard.post {
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setCardEnabledState(holder, rule.isEnabled)
|
||||||
|
setExpandedState(holder, isExpanded)
|
||||||
|
|
||||||
|
holder.progressBar.setProgress(rule.progress, true)
|
||||||
|
holder.textProgressPercent.text = "${rule.progress}%"
|
||||||
|
|
||||||
|
holder.btnExpand.setOnClickListener {
|
||||||
|
toggleExpand(position)
|
||||||
|
}
|
||||||
|
holder.btnEdit.setOnClickListener { onEditImmich(rule) }
|
||||||
|
holder.btnDelete.setOnClickListener { onDeleteImmich(rule) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toggleExpand(position: Int) {
|
||||||
|
if (expandedPositions.contains(position)) {
|
||||||
|
expandedPositions.remove(position)
|
||||||
|
} else {
|
||||||
|
expandedPositions.add(position)
|
||||||
|
}
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupFileTypeChip(chip: Chip, category: FileTypeCategory) {
|
||||||
|
val text = when (category) {
|
||||||
|
FileTypeCategory.ALL -> "Todos"
|
||||||
|
FileTypeCategory.DOCUMENTS -> "Documentos"
|
||||||
|
FileTypeCategory.IMAGES -> "Imágenes"
|
||||||
|
FileTypeCategory.VIDEOS -> "Videos"
|
||||||
|
}
|
||||||
|
chip.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setCardEnabledState(holder: ViewHolder, isEnabled: Boolean) {
|
||||||
|
val alpha = if (isEnabled) 1.0f else 0.6f
|
||||||
|
holder.rootCard.alpha = alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setExpandedState(holder: ViewHolder, isExpanded: Boolean) {
|
||||||
|
holder.layoutDetails.visibility = if (isExpanded) View.VISIBLE else View.GONE
|
||||||
|
holder.btnExpand.text = if (isExpanded) "⌃" else "⌄"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
override fun onViewRecycled(holder: ViewHolder) {
|
||||||
|
super.onViewRecycled(holder)
|
||||||
|
viewHolderMap.entries.removeIf { it.value == holder }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateRules(folderRules: List<FolderRule>, immichRules: List<ImmichUploadRule>) {
|
||||||
|
items.clear()
|
||||||
|
items.addAll(folderRules.map { RuleListItem.Folder(it) })
|
||||||
|
items.addAll(immichRules.map { RuleListItem.Immich(it) })
|
||||||
|
expandedPositions.clear()
|
||||||
|
viewHolderMap.clear()
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateProgressOnly(folderRules: List<FolderRule>, immichRules: List<ImmichUploadRule>) {
|
||||||
|
folderRules.forEach { folderRule ->
|
||||||
|
val holder = viewHolderMap[folderRule.id]
|
||||||
|
holder?.progressBar?.setProgress(folderRule.progress, true)
|
||||||
|
holder?.textProgressPercent?.text = "${folderRule.progress}%"
|
||||||
|
}
|
||||||
|
immichRules.forEach { immichRule ->
|
||||||
|
val holder = viewHolderMap[immichRule.id]
|
||||||
|
holder?.progressBar?.setProgress(immichRule.progress, true)
|
||||||
|
holder?.textProgressPercent?.text = "${immichRule.progress}%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addFolderRule(rule: FolderRule) {
|
||||||
|
items.add(RuleListItem.Folder(rule))
|
||||||
|
notifyItemInserted(items.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addImmichRule(rule: ImmichUploadRule) {
|
||||||
|
items.add(RuleListItem.Immich(rule))
|
||||||
|
notifyItemInserted(items.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateFolderRule(rule: FolderRule) {
|
||||||
|
val position = items.indexOfFirst { it is RuleListItem.Folder && it.rule.id == rule.id }
|
||||||
|
if (position != -1) {
|
||||||
|
viewHolderMap.remove(rule.id)
|
||||||
|
items[position] = RuleListItem.Folder(rule)
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateImmichRule(rule: ImmichUploadRule) {
|
||||||
|
val position = items.indexOfFirst { it is RuleListItem.Immich && it.rule.id == rule.id }
|
||||||
|
if (position != -1) {
|
||||||
|
viewHolderMap.remove(rule.id)
|
||||||
|
items[position] = RuleListItem.Immich(rule)
|
||||||
|
notifyItemChanged(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeFolderRule(rule: FolderRule) {
|
||||||
|
val position = items.indexOfFirst { it is RuleListItem.Folder && it.rule.id == rule.id }
|
||||||
|
if (position != -1) {
|
||||||
|
viewHolderMap.remove(rule.id)
|
||||||
|
items.removeAt(position)
|
||||||
|
notifyItemRemoved(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeImmichRule(rule: ImmichUploadRule) {
|
||||||
|
val position = items.indexOfFirst { it is RuleListItem.Immich && it.rule.id == rule.id }
|
||||||
|
if (position != -1) {
|
||||||
|
viewHolderMap.remove(rule.id)
|
||||||
|
items.removeAt(position)
|
||||||
|
notifyItemRemoved(position)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.folderredirect.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.BackoffPolicy
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class WorkManagerScheduler(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val WORK_NAME = "folder_redirect_background_work"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleBackgroundWork() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiresBatteryNotLow(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val workRequest = PeriodicWorkRequestBuilder<FileMoveWorker>(15, TimeUnit.MINUTES)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.REPLACE,
|
||||||
|
workRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
android.util.Log.d("WorkManagerScheduler", "WorkManager programado (15 min)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelBackgroundWork() {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
android.util.Log.d("WorkManagerScheduler", "WorkManager cancelado")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun triggerImmediateWork() {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiresBatteryNotLow(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val oneTimeRequest = OneTimeWorkRequestBuilder<FileMoveWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueue(oneTimeRequest)
|
||||||
|
android.util.Log.d("WorkManagerScheduler", "WorkManager inmediato ejecutado")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M10,4H4C2.9,4 2,4.9 2,6V18C2,19.1 2.9,20 4,20H20C21.1,20 22,19.1 22,18V8C22,6.9 21.1,6 20,6H12L10,4Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<group android:scaleX="2.5"
|
||||||
|
android:scaleY="2.5"
|
||||||
|
android:translateX="22"
|
||||||
|
android:translateY="22">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M10,4H4C2.9,4 2,4.9 2,6V18C2,19.1 2.9,20 4,20H20C21.1,20 22,19.1 22,18V8C22,6.9 21.1,6 20,6H12L10,4Z"/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M16,12L20,16L16,20"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:fillAlpha="0"/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M8,14L16,14"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeColor="#FFFFFF"/>
|
||||||
|
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#FFFFFF" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="#CCCCCC" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.MaterialToolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
android:background="?attr/colorPrimaryContainer"
|
||||||
|
app:title="@string/app_name"
|
||||||
|
app:titleTextColor="?attr/colorOnPrimaryContainer"
|
||||||
|
app:menu="@menu/menu_main" />
|
||||||
|
|
||||||
|
<com.google.android.material.tabs.TabLayout
|
||||||
|
android:id="@+id/tabLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/colorPrimaryContainer"
|
||||||
|
app:tabTextColor="?attr/colorOnPrimaryContainer"
|
||||||
|
app:tabSelectedTextColor="?attr/colorOnPrimaryContainer"
|
||||||
|
app:tabIndicatorColor="?attr/colorOnPrimaryContainer" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.viewpager2.widget.ViewPager2
|
||||||
|
android:id="@+id/viewPager"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom|start"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="start">
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/btnAddImmich"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:src="@android:drawable/ic_menu_upload"
|
||||||
|
android:contentDescription="Agregar regla Immich"
|
||||||
|
android:backgroundTint="?attr/colorSecondaryContainer"
|
||||||
|
android:tint="?attr/colorOnSecondaryContainer" />
|
||||||
|
|
||||||
|
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||||
|
android:id="@+id/btnAdd"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:src="@android:drawable/ic_input_add"
|
||||||
|
android:contentDescription="Agregar regla" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Nombre de la regla"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editRuleName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Descripción (opcional)"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editRuleDescription"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:minLines="2" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="Carpeta a Monitorear"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editSource"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:hint="/storage/emulated/0/Download" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSelectSource"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Seleccionar"
|
||||||
|
android:minWidth="48dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Immich URL (ej: https://immich.example.com)"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
app:startIconDrawable="@android:drawable/ic_menu_upload">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editImmichUrl"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="API Key"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
app:endIconMode="password_toggle">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editApiKey"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Tipo de archivo"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
android:id="@+id/radioGroupFileType"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioImages"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Imágenes (jpg, png, gif, webp...)"
|
||||||
|
android:checked="true" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioVideos"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Videos (mp4, avi, mkv, mov...)" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioAll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Todos los archivos" />
|
||||||
|
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Filtro por nombre (opcional)"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="Ej: RDT*, IMG_*, *.jpg"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editNamePattern"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Usa * como comodín (ej: IMG_* = IMG_001.jpg, IMG_002.png)"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textColor="@android:color/darker_gray" />
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorPrimaryContainer"
|
||||||
|
app:cardCornerRadius="8dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Información"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnPrimaryContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Las subcarpetas dentro de la carpeta monitoreada se usarán como nombres de álbum en Immich. Los archivos sin subcarpeta se subirán sin álbum."
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="?attr/colorOnPrimaryContainer" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:minWidth="300dp"
|
||||||
|
android:minHeight="400dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Registro de Actividad"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnClearLogs"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Limpiar"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:textColor="?attr/colorError" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textLogEmpty"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="No hay actividad registrada"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:gravity="center"
|
||||||
|
android:visibility="visible" />
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginTop="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textLogs"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:lineSpacingExtra="2dp"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="Nombre de la regla"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editRuleName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="Descripción (opcional)"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editRuleDescription"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textMultiLine"
|
||||||
|
android:maxLines="3"
|
||||||
|
android:minLines="2" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="Carpeta de Origen"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editSource"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:hint="/storage/emulated/0/Download" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSelectSource"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Seleccionar"
|
||||||
|
android:minWidth="48dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:hint="Carpeta de Destino"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editDestination"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:hint="/storage/emulated/0/..." />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSelectDestination"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Seleccionar"
|
||||||
|
android:minWidth="48dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Tipo de archivo"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
android:id="@+id/radioGroupFileType"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioAll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Todos los archivos"
|
||||||
|
android:checked="true" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioDocuments"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Documentos (pdf, doc, xlsx, txt...)" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioImages"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Imágenes (jpg, png, gif, webp...)" />
|
||||||
|
|
||||||
|
<RadioButton
|
||||||
|
android:id="@+id/radioVideos"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Videos (mp4, avi, mkv, mov...)" />
|
||||||
|
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="Filtro por nombre (opcional)"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:hint="Ej: RDT*, invoice_*, *.pdf"
|
||||||
|
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editNamePattern"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Usa * como comodín (ej: RDT* = RDT123.jpg, RDT456.png)"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:textColor="@android:color/darker_gray" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="8dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
style="@style/Widget.MaterialComponents.CardView">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textRuleName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textRuleDescription"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnExpand"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="⌄"
|
||||||
|
android:textSize="20sp"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:minWidth="48dp" />
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchEnabled"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutDetails"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:layout_marginTop="12dp">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="?attr/colorOutline" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="Origen:"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="?attr/colorPrimary" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textSource"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:ellipsize="middle"
|
||||||
|
android:singleLine="true" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Destino:"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="?attr/colorPrimary" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textDestination"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:ellipsize="middle"
|
||||||
|
android:singleLine="true" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textImmichUrl"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Immich URL:"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="?attr/colorPrimary"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textImmichUrlValue"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurface"
|
||||||
|
android:ellipsize="middle"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipFileType"
|
||||||
|
style="@style/Widget.Material3.Chip.Assist"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
<com.google.android.material.chip.Chip
|
||||||
|
android:id="@+id/chipNamePattern"
|
||||||
|
style="@style/Widget.Material3.Chip.Assist"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textProgressPercent"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:text="0%" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="end">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnEdit"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:icon="@android:drawable/ic_menu_edit"
|
||||||
|
android:text="Editar"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnDelete"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:icon="@android:drawable/ic_menu_delete"
|
||||||
|
android:text="Eliminar"
|
||||||
|
style="@style/Widget.Material3.Button.TextButton"
|
||||||
|
android:textColor="?attr/colorError" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:padding="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/emptyView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="No hay reglas configuradas\nPresiona + para agregar una"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Servicio de Monitoreo"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textServiceStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Detenido"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchService"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardMoveExisting"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Mover Archivos Existentes"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Procesa archivos que ya están en la carpeta de origen"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnMoveExisting"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Mover"
|
||||||
|
style="@style/Widget.Material3.Button" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardViewLogs"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Registro de Actividad"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="?attr/colorOnSurface" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="Ver archivos movidos y subidos"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnViewLogs"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="Ver"
|
||||||
|
style="@style/Widget.Material3.Button" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp"
|
||||||
|
android:background="@drawable/widget_background">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Folder Redirect"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#000000"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnExecuteTasks"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Ejecutar Tareas"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/textStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="Listo"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:gravity="center" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item
|
||||||
|
android:id="@+id/action_view_logs"
|
||||||
|
android:title="Registro"
|
||||||
|
android:icon="@android:drawable/ic_menu_view"
|
||||||
|
android:showAsAction="ifRoom" />
|
||||||
|
</menu>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/purple_500"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/purple_500"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Folder Redirect App</string>
|
||||||
|
<string name="widget_execute_tasks_title">Ejecutar Tareas</string>
|
||||||
|
<string name="widget_execute_tasks_desc">Ejecuta todas las tareas pendientes y cierra</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.FolderRedirectApp" parent="Theme.Material3.DynamicColors.DayNight">
|
||||||
|
<item name="android:statusBarColor">?attr/colorPrimary</item>
|
||||||
|
<item name="android:windowLightStatusBar">?attr/isLightTheme</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:minWidth="180dp"
|
||||||
|
android:minHeight="110dp"
|
||||||
|
android:updatePeriodMillis="0"
|
||||||
|
android:initialLayout="@layout/widget_execute_tasks"
|
||||||
|
android:resizeMode="horizontal|vertical"
|
||||||
|
android:widgetCategory="home_screen"
|
||||||
|
android:label="@string/widget_execute_tasks_title"
|
||||||
|
android:description="@string/widget_execute_tasks_desc"
|
||||||
|
android:previewImage="@drawable/ic_folder" />
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.1.0' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '1.9.0' apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
task clean(type: Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
#
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
# Default value: -Xmx1024m -XX:MaxPermSize=256m
|
||||||
|
# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
|
#
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. More details, visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
#Sun May 24 2026
|
||||||
|
org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M"
|
||||||
|
android.useAndroidX=true
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "FolderRedirectApp"
|
||||||
|
include ':app'
|
||||||