From 57f8858708e6cf15ebd03107b5d0c75baafd2945 Mon Sep 17 00:00:00 2001 From: Nino Date: Mon, 27 Jul 2026 20:26:31 -0600 Subject: [PATCH] v1.0.6: Add Sync All and Clean Cache features - Sync All button: downloads all pages from all spaces recursively - Skips already-cached pages (checks updatedAt to avoid re-downloading) - Progress dialog shows space/page counter during sync - Cancel support via dialog cancel button - Clean Cache button: removes all downloaded pages with confirmation - Preserves local drafts when cleaning cache - Added to Settings > Offline section --- app/build.gradle.kts | 4 +- .../java/com/docmost/app/api/CachedApi.kt | 106 ++++++++++++++++++ .../com/docmost/app/data/PageCacheManager.kt | 16 +++ .../java/com/docmost/app/ui/SpacesActivity.kt | 81 +++++++++++++ app/src/main/res/layout/activity_spaces.xml | 18 +++ app/src/main/res/values/strings.xml | 11 ++ 6 files changed, 234 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fb334d8..1e76a0c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { applicationId = "com.docmost.app" minSdk = 26 targetSdk = 34 - versionCode = 6 - versionName = "1.0.5" + versionCode = 7 + versionName = "1.0.6" } signingConfigs { diff --git a/app/src/main/java/com/docmost/app/api/CachedApi.kt b/app/src/main/java/com/docmost/app/api/CachedApi.kt index eab339d..1ea1a9e 100644 --- a/app/src/main/java/com/docmost/app/api/CachedApi.kt +++ b/app/src/main/java/com/docmost/app/api/CachedApi.kt @@ -3,6 +3,7 @@ package com.docmost.app.api import android.content.Context import android.net.Uri import android.util.Log +import com.docmost.app.BuildConfig import com.docmost.app.utils.NetworkConnectivityObserver import com.docmost.app.data.PageCacheManager import com.docmost.app.data.SpaceCacheManager @@ -18,10 +19,23 @@ class CachedApi( private val networkObserver: NetworkConnectivityObserver ) { var forcedOffline: Boolean = false + var isSyncing: Boolean = false + private set val isOffline: Boolean get() = forcedOffline || networkObserver.isOffline() + data class SyncProgress( + val spaceName: String, + val spaceIndex: Int, + val totalSpaces: Int, + val pageTitle: String, + val pageCount: Int, + val totalPages: Int, + val skipped: Int, + val errors: Int + ) + private fun cacheOrThrow(block: () -> T): T { return try { block() @@ -365,4 +379,96 @@ class CachedApi( } return fileName ?: uri.lastPathSegment } + + // ── Sync All ── + + suspend fun syncAllPages(onProgress: (SyncProgress) -> Unit): Pair { + if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión") + if (isSyncing) throw IllegalStateException("Sync already in progress") + isSyncing = true + var skipped = 0 + var errors = 0 + var totalSynced = 0 + try { + val spaces = api.getSpaces() + spaceCache.cacheSpaces(spaces) + val allRootPages = mutableMapOf>() + + for ((spaceIdx, space) in spaces.withIndex()) { + try { + val rootPages = api.getSidebarPages(space.id) + allRootPages[space.id] = rootPages + pageCache.cachePagesForSpace(space.id, rootPages) + + val allPageIds = mutableListOf() + allPageIds.addAll(rootPages) + rootPages.forEach { page -> + if (page.hasChildren) { + val children = fetchAllChildren(space.id, page) + allPageIds.addAll(children) + } + } + + var pageIdx = 0 + for (page in allPageIds) { + if (!isSyncing) break + pageIdx++ + onProgress(SyncProgress( + spaceName = space.name, + spaceIndex = spaceIdx + 1, + totalSpaces = spaces.size, + pageTitle = page.title, + pageCount = pageIdx, + totalPages = allPageIds.size, + skipped = skipped, + errors = errors + )) + try { + val cached = pageCache.getCachedPage(page.id) + if (cached != null && cached.updatedAt == page.updatedAt) { + skipped++ + continue + } + val fetchedPage = api.getPageInfo(page.id) + pageCache.cachePage(fetchedPage) + totalSynced++ + } catch (e: Exception) { + errors++ + if (BuildConfig.DEBUG) Log.e("CachedApi", "Sync page error: ${e.message}") + } + } + } catch (e: Exception) { + errors++ + if (BuildConfig.DEBUG) Log.e("CachedApi", "Sync space error: ${e.message}") + } + } + } finally { + isSyncing = false + } + return Pair(totalSynced, errors) + } + + private suspend fun fetchAllChildren(spaceId: String, parentPage: Page): List { + val children = mutableListOf() + try { + val childPages = api.getSidebarPages(spaceId, parentPage.id) + children.addAll(childPages) + childPages.forEach { child -> + if (child.hasChildren) { + children.addAll(fetchAllChildren(spaceId, child)) + } + } + } catch (_: Exception) { } + return children + } + + fun stopSync() { + isSyncing = false + } + + fun clearAllCachedPages(): Int { + return pageCache.clearAllCachedPages() + } + + fun getCachedPageCount(): Int = pageCache.getCachedPageCount() } diff --git a/app/src/main/java/com/docmost/app/data/PageCacheManager.kt b/app/src/main/java/com/docmost/app/data/PageCacheManager.kt index 45aff52..d7bd703 100644 --- a/app/src/main/java/com/docmost/app/data/PageCacheManager.kt +++ b/app/src/main/java/com/docmost/app/data/PageCacheManager.kt @@ -175,6 +175,22 @@ class PageCacheManager(private val context: Context) { fun getCachedPageCount(): Int = getIndex().size + fun clearAllCachedPages(): Int { + var count = 0 + try { + val index = getIndex() + count = index.size + cacheDir.listFiles()?.forEach { file -> + if (file.isFile && file.name != "index.json") { + file.delete() + } + } + spacePagesDir.listFiles()?.forEach { it.delete() } + indexFile.writeText("[]") + } catch (_: Exception) { } + return count + } + private fun getIndex(): List { return try { if (!indexFile.exists()) return emptyList() diff --git a/app/src/main/java/com/docmost/app/ui/SpacesActivity.kt b/app/src/main/java/com/docmost/app/ui/SpacesActivity.kt index eb39d0a..d296a17 100644 --- a/app/src/main/java/com/docmost/app/ui/SpacesActivity.kt +++ b/app/src/main/java/com/docmost/app/ui/SpacesActivity.kt @@ -285,6 +285,14 @@ class SpacesActivity : AppCompatActivity() { showCachedPagesDialog() } + binding.btnSyncAll.setOnClickListener { + startSyncAll() + } + + binding.btnCleanCache.setOnClickListener { + showCleanCacheDialog() + } + binding.btnDeletedPages.setOnClickListener { showDeletedPagesDialog() } @@ -1651,6 +1659,79 @@ class SpacesActivity : AppCompatActivity() { Toast.makeText(this, R.string.link_copied, Toast.LENGTH_SHORT).show() } + private var syncDialog: android.app.AlertDialog? = null + + private fun startSyncAll() { + if (cachedApi.isOffline) { + Toast.makeText(this, R.string.offline_not_available, Toast.LENGTH_SHORT).show() + return + } + if (cachedApi.isSyncing) { + Toast.makeText(this, R.string.syncing, Toast.LENGTH_SHORT).show() + return + } + + val progressDialog = android.app.AlertDialog.Builder(this) + .setTitle(R.string.syncing) + .setMessage(getString(R.string.sync_progress, 0, 0, 0, 0)) + .setCancelable(true) + .setOnCancelListener { + cachedApi.stopSync() + } + .create() + progressDialog.show() + syncDialog = progressDialog + + CoroutineScope(Dispatchers.IO).launch { + try { + val (synced, errors) = cachedApi.syncAllPages { progress -> + runOnUiThread { + if (progressDialog.isShowing) { + progressDialog.setMessage(getString(R.string.sync_progress, + progress.spaceIndex, progress.totalSpaces, + progress.pageCount, progress.totalPages)) + } + } + } + withContext(Dispatchers.Main) { + if (progressDialog.isShowing) progressDialog.dismiss() + Toast.makeText(this@SpacesActivity, + getString(R.string.sync_complete, synced, 0, errors), + Toast.LENGTH_LONG).show() + loadSpaces() + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + if (progressDialog.isShowing) progressDialog.dismiss() + if (e.message?.contains("cancelled", true) == true || e is kotlinx.coroutines.CancellationException) { + Toast.makeText(this@SpacesActivity, R.string.sync_cancelled, Toast.LENGTH_SHORT).show() + } else { + Toast.makeText(this@SpacesActivity, e.message ?: "Error", Toast.LENGTH_LONG).show() + } + } + } + } + } + + private fun showCleanCacheDialog() { + val count = cachedApi.getCachedPageCount() + if (count == 0) { + Toast.makeText(this, R.string.no_cached_pages, Toast.LENGTH_SHORT).show() + return + } + + com.google.android.material.dialog.MaterialAlertDialogBuilder(this) + .setTitle(R.string.clean_cache) + .setMessage(getString(R.string.clean_confirm) + "\n\n" + getString(R.string.cached_count, count)) + .setPositiveButton(R.string.clean_cache) { _, _ -> + val removed = cachedApi.clearAllCachedPages() + Toast.makeText(this, getString(R.string.cache_cleaned, removed), Toast.LENGTH_SHORT).show() + loadSpaces() + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun showCachedPagesDialog() { val cachedPages = pageCacheManager.getCachedPages() if (cachedPages.isEmpty()) { diff --git a/app/src/main/res/layout/activity_spaces.xml b/app/src/main/res/layout/activity_spaces.xml index 0881c00..099360a 100644 --- a/app/src/main/res/layout/activity_spaces.xml +++ b/app/src/main/res/layout/activity_spaces.xml @@ -471,13 +471,31 @@ android:text="@string/force_offline" android:layout_marginBottom="12dp" /> + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ef17c8..366eb9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,4 +117,15 @@ Páginas eliminadas Ver y restaurar páginas eliminadas recientemente 🗑️ Páginas eliminadas + Sincronizar todo + Descargar todas las páginas para uso sin conexión + Limpiar caché + Eliminar todas las páginas descargadas + Sincronizando… + Espacio %1$d/%2$d · Página %3$d/%4$d + Sincronización completa: %1$d descargadas, %2$d actualizadas, %3$d errores + Sincronización cancelada + ¿Eliminar todas las páginas descargadas? Las borradores locales se conservarán. + Caché limpiada: %d páginas eliminadas + Páginas en caché: %d