Initial commit - Folder Redirect App

This commit is contained in:
2026-07-27 11:08:40 -06:00
commit a67ca94be7
57 changed files with 4040 additions and 0 deletions
+48
View File
@@ -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'
}
+2
View File
@@ -0,0 +1,2 @@
-dontwarn org.jetbrains.kotlin.**
-keep class com.folderredirect.app.** { *; }
+62
View File
@@ -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")
}
}
+11
View File
@@ -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>
+72
View File
@@ -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>
+184
View File
@@ -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>
+23
View File
@@ -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>
+167
View File
@@ -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>
+8
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+10
View File
@@ -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>
+6
View File
@@ -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>
+7
View File
@@ -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" />