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
This commit is contained in:
2026-07-27 20:26:31 -06:00
parent 922d31b7df
commit 57f8858708
6 changed files with 234 additions and 2 deletions

View File

@@ -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 {

View File

@@ -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 <T> 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<Int, Int> {
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<String, List<Page>>()
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<Page>()
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<Page> {
val children = mutableListOf<Page>()
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()
}

View File

@@ -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<CachedPageMeta> {
return try {
if (!indexFile.exists()) return emptyList()

View File

@@ -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()) {

View File

@@ -471,13 +471,31 @@
android:text="@string/force_offline"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSyncAll"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/sync_all"
android:icon="@android:drawable/ic_menu_save"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCachedPages"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/cached_pages"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCleanCache"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/clean_cache"
android:icon="@drawable/ic_delete"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -117,4 +117,15 @@
<string name="deleted_pages">Páginas eliminadas</string>
<string name="deleted_pages_description">Ver y restaurar páginas eliminadas recientemente</string>
<string name="deleted_pages_button">🗑️ Páginas eliminadas</string>
<string name="sync_all">Sincronizar todo</string>
<string name="sync_all_description">Descargar todas las páginas para uso sin conexión</string>
<string name="clean_cache">Limpiar caché</string>
<string name="clean_cache_description">Eliminar todas las páginas descargadas</string>
<string name="syncing">Sincronizando…</string>
<string name="sync_progress">Espacio %1$d/%2$d · Página %3$d/%4$d</string>
<string name="sync_complete">Sincronización completa: %1$d descargadas, %2$d actualizadas, %3$d errores</string>
<string name="sync_cancelled">Sincronización cancelada</string>
<string name="clean_confirm">¿Eliminar todas las páginas descargadas? Las borradores locales se conservarán.</string>
<string name="cache_cleaned">Caché limpiada: %d páginas eliminadas</string>
<string name="cached_count">Páginas en caché: %d</string>
</resources>