4 Commits

Author SHA1 Message Date
d9f0a80dd4 v1.0.10: Fix checkbox-text alignment in read-only mode 2026-07-27 21:42:34 -06:00
b640f1f063 v1.0.9: Fix TODO/checklist layout in read-only mode 2026-07-27 21:37:06 -06:00
c1eec3cf18 v1.0.8: Improve offline pages dialog UI - search, dates, cleaner layout 2026-07-27 21:21:03 -06:00
f265498ed3 v1.0.7: Fix false offline modifications causing 'untitled' pages
Root cause chain:
1. JS _pendingSet cleared prematurely in onReady (0ms), letting onChange
   fire during initial content load and set userEdited=true
2. onAutoSave didn't check isEditorReadOnly, firing even for read-only pages
3. Markdown round-trip (extractText → markdownToBlocks → blocksToMarkdown)
   produced different output, triggering false change detection
4. onSaveContent passed null for title when no title changes, which
   CachedApi converted to empty string, causing server to set title to 'untitled'

Fixes:
- JS: Remove premature _pendingSet clearing in onReady; increase timeout to 500ms
- Kotlin: onAutoSave checks isEditorReadOnly (no auto-save in read-only mode)
- Kotlin: Save operations always pass current title (never null) to prevent
  empty title being sent to server
- Kotlin: Add isContentLoading flag to suppress onContentChanged during initial
  content load, preventing false currentPageContent updates
- Added APK versioned naming for Obtanium compatibility
2026-07-27 20:56:59 -06:00
8 changed files with 224 additions and 33 deletions

View File

@@ -20,8 +20,8 @@ android {
applicationId = "com.docmost.app" applicationId = "com.docmost.app"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 7 versionCode = 11
versionName = "1.0.6" versionName = "1.0.10"
} }
signingConfigs { signingConfigs {
@@ -58,6 +58,13 @@ android {
viewBinding = true viewBinding = true
buildConfig = true buildConfig = true
} }
applicationVariants.all {
outputs.all {
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
output.outputFileName = "Docmost-app-v${versionName}-${buildType.name}.apk"
}
}
} }
dependencies { dependencies {

View File

@@ -531,12 +531,9 @@
} else { } else {
console.log('AndroidInterface not available yet'); console.log('AndroidInterface not available yet');
} }
// Android controla la carga de contenido via setContent() // _pendingSet is cleared by setContent() after 100ms
// No cargar pendingAndroidContent automáticamente // Do NOT clear it here — doing so prematurely lets onChange
// Clear pending set flag on next tick after initialization // fire during initial content load and sets userEdited=true
setTimeout(function() {
window._pendingSet = false;
}, 0);
// Configurar observer para detectar cambios en bloques vacíos // Configurar observer para detectar cambios en bloques vacíos
setTimeout(function() { setTimeout(function() {
setupBackspaceHandler(); setupBackspaceHandler();
@@ -617,7 +614,7 @@
window.setContentLock = false; window.setContentLock = false;
setTimeout(function() { setTimeout(function() {
window._pendingSet = false; window._pendingSet = false;
}, 100); }, 500);
}; };
window.getContent = function() { window.getContent = function() {

View File

@@ -283,6 +283,7 @@ class PageEditorActivity : AppCompatActivity() {
} }
private var isEditorRendered = false private var isEditorRendered = false
private var isContentLoading = false
private var pendingContent: String? = null private var pendingContent: String? = null
private var isTitleVisible = true private var isTitleVisible = true
private var isAnimating = false private var isAnimating = false
@@ -627,6 +628,7 @@ class PageEditorActivity : AppCompatActivity() {
@JavascriptInterface @JavascriptInterface
fun onContentChanged(markdown: String) { fun onContentChanged(markdown: String) {
if (isContentLoading) return
currentPageContent = markdown currentPageContent = markdown
} }
@@ -640,12 +642,15 @@ class PageEditorActivity : AppCompatActivity() {
loadEditorContentInternal(content) loadEditorContentInternal(content)
pendingContent = null pendingContent = null
} }
Handler(Looper.getMainLooper()).postDelayed({
isContentLoading = false
}, 600)
} }
} }
@JavascriptInterface @JavascriptInterface
fun onAutoSave(markdown: String) { fun onAutoSave(markdown: String) {
if (!canEdit) return if (!canEdit || isEditorReadOnly) return
runOnUiThread { runOnUiThread {
val title = binding.etTitle.text.toString().trim() val title = binding.etTitle.text.toString().trim()
@@ -698,14 +703,14 @@ class PageEditorActivity : AppCompatActivity() {
try { try {
cachedApi.updatePage( cachedApi.updatePage(
currentPageId, markdown, currentPageId, markdown,
if (hasTitleChanges) title else null, title,
if (hasEmojiChanges) currentEmoji else null if (hasEmojiChanges) currentEmoji else null
) )
val cachedPage = pageCacheManager.getCachedPage(currentPageId) val cachedPage = pageCacheManager.getCachedPage(currentPageId)
if (cachedPage != null) { if (cachedPage != null) {
pageCacheManager.cachePage(cachedPage.copy( pageCacheManager.cachePage(cachedPage.copy(
title = if (hasTitleChanges) title else cachedPage.title, title = title,
icon = if (hasEmojiChanges) currentEmoji else cachedPage.icon, icon = if (hasEmojiChanges) currentEmoji else cachedPage.icon,
content = markdown content = markdown
)) ))
@@ -740,7 +745,7 @@ class PageEditorActivity : AppCompatActivity() {
try { try {
cachedApi.updatePage( cachedApi.updatePage(
currentPageId, null, currentPageId, null,
if (hasTitleChanges) title else null, title,
if (hasEmojiChanges) currentEmoji else null if (hasEmojiChanges) currentEmoji else null
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -768,7 +773,7 @@ class PageEditorActivity : AppCompatActivity() {
cachedApi.updatePage( cachedApi.updatePage(
pageId!!, pageId!!,
if (hasBodyChanges) markdown else null, if (hasBodyChanges) markdown else null,
if (hasTitleChanges) title else null, title,
if (pageEmoji != originalEmoji) pageEmoji else null if (pageEmoji != originalEmoji) pageEmoji else null
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -1004,6 +1009,7 @@ class PageEditorActivity : AppCompatActivity() {
private fun loadEditorContent(markdown: String) { private fun loadEditorContent(markdown: String) {
if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadEditorContent called with ${markdown.length} chars") if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadEditorContent called with ${markdown.length} chars")
isContentLoading = true
if (isEditorRendered) { if (isEditorRendered) {
loadEditorContentInternal(markdown) loadEditorContentInternal(markdown)
} else { } else {
@@ -2194,6 +2200,10 @@ class PageEditorActivity : AppCompatActivity() {
a { a {
color: #64b5f6 !important; color: #64b5f6 !important;
} }
ul[data-type="taskList"] li > label > input[type="checkbox"] {
border-color: #666 !important;
accent-color: #369FFF;
}
""" else "" """ else ""
return """ return """
@@ -2229,6 +2239,12 @@ class PageEditorActivity : AppCompatActivity() {
th, td { border: 1px solid #ddd; padding: 8px; white-space: nowrap; } th, td { border: 1px solid #ddd; padding: 8px; white-space: nowrap; }
th { background: #f4f4f4; } th { background: #f4f4f4; }
a { color: #0066cc; word-wrap: break-word; overflow-wrap: break-word; } a { color: #0066cc; word-wrap: break-word; overflow-wrap: break-word; }
ul[data-type="taskList"] { list-style: none; padding-left: 0; margin: 8px 0; }
ul[data-type="taskList"] li { display: flex; align-items: flex-start; margin: 4px 0; }
ul[data-type="taskList"] li > label { flex-shrink: 0; margin-right: 8px; display: flex; align-items: center; padding-top: 2px; }
ul[data-type="taskList"] li > label > input[type="checkbox"] { width: 16px; height: 16px; cursor: default; }
ul[data-type="taskList"] li > div { flex: 1; }
ul[data-type="taskList"] li > div > p { margin: 0; }
$darkModeCss $darkModeCss
</style> </style>
<script> <script>

View File

@@ -1739,39 +1739,83 @@ class SpacesActivity : AppCompatActivity() {
return return
} }
val view = layoutInflater.inflate(R.layout.dialog_search, null) val view = layoutInflater.inflate(R.layout.dialog_cached_pages, null)
val tvTitle = view.findViewById<TextView>(R.id.tvDialogTitle)
val tvSubtitle = view.findViewById<TextView>(R.id.tvDialogSubtitle)
val etSearch = view.findViewById<android.widget.EditText>(R.id.etSearch) val etSearch = view.findViewById<android.widget.EditText>(R.id.etSearch)
val rvResults = view.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.rvSearchResults) val rvResults = view.findViewById<RecyclerView>(R.id.rvCachedPages)
val tvEmpty = view.findViewById<TextView>(R.id.tvSearchEmpty) val tvEmpty = view.findViewById<TextView>(R.id.tvEmpty)
etSearch.visibility = View.GONE
tvEmpty.visibility = View.GONE val allPages = cachedPages.sortedByDescending { it.updatedAt }.toMutableList()
val filteredPages = mutableListOf<com.docmost.app.data.CachedPageMeta>()
tvTitle.text = getString(R.string.cached_pages)
tvSubtitle.text = getString(R.string.cached_count, allPages.size)
tvEmpty.text = getString(R.string.no_cached_pages_found)
rvResults.layoutManager = LinearLayoutManager(this) rvResults.layoutManager = LinearLayoutManager(this)
val results = cachedPages.toMutableList()
val adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter<androidx.recyclerview.widget.RecyclerView.ViewHolder>() { val dateFormat = java.text.SimpleDateFormat("dd MMM yyyy", java.util.Locale.getDefault())
override fun getItemCount() = results.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { fun formatDate(millis: Long): String {
val v = layoutInflater.inflate(android.R.layout.simple_list_item_2, parent, false) return try {
return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {} dateFormat.format(java.util.Date(millis))
} catch (_: Exception) {
""
} }
override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { }
val r = results[position]
val text1 = holder.itemView.findViewById<android.widget.TextView>(android.R.id.text1) fun updateList(pages: List<com.docmost.app.data.CachedPageMeta>) {
val text2 = holder.itemView.findViewById<android.widget.TextView>(android.R.id.text2) filteredPages.clear()
text1.text = if (r.icon.isNullOrBlank()) r.title else "${r.icon} ${r.title}" filteredPages.addAll(pages)
text2.text = r.spaceId ?: getString(R.string.offline_mode) rvResults.adapter?.notifyDataSetChanged()
tvEmpty.visibility = if (filteredPages.isEmpty()) View.VISIBLE else View.GONE
rvResults.visibility = if (filteredPages.isEmpty()) View.GONE else View.VISIBLE
}
val adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemCount() = filteredPages.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val v = layoutInflater.inflate(R.layout.item_cached_page, parent, false)
return object : RecyclerView.ViewHolder(v) {}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val r = filteredPages[position]
val tvIcon = holder.itemView.findViewById<TextView>(R.id.tvPageIcon)
val tvItemTitle = holder.itemView.findViewById<TextView>(R.id.tvPageTitle)
val tvDate = holder.itemView.findViewById<TextView>(R.id.tvPageDate)
tvIcon.text = if (r.icon.isNullOrBlank()) "📄" else r.icon
tvItemTitle.text = r.title
tvDate.text = formatDate(r.updatedAt)
holder.itemView.setOnClickListener { holder.itemView.setOnClickListener {
openPageEditor(Page(id = r.pageId, title = r.title, icon = r.icon, spaceId = r.spaceId)) openPageEditor(Page(id = r.pageId, title = r.title, icon = r.icon, spaceId = r.spaceId))
} }
} }
} }
rvResults.adapter = adapter rvResults.adapter = adapter
updateList(allPages)
val maxHeight = (resources.displayMetrics.heightPixels * 0.6).toInt() val maxHeight = (resources.displayMetrics.heightPixels * 0.7).toInt()
rvResults.layoutParams.height = maxHeight rvResults.layoutParams.height = maxHeight
val dialog = BottomSheetDialog(this) etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val query = s?.toString()?.trim() ?: ""
if (query.isEmpty()) {
updateList(allPages)
return
}
val lowerQuery = query.lowercase()
val filtered = allPages.filter { it.title.lowercase().contains(lowerQuery) }
updateList(filtered)
}
})
val dialog = com.google.android.material.bottomsheet.BottomSheetDialog(this)
dialog.setContentView(view) dialog.setContentView(view)
dialog.show() dialog.show()
} }

View File

@@ -140,6 +140,29 @@ object ContentConverter {
} }
sb.append("</li>") sb.append("</li>")
} }
"taskList" -> {
sb.append("<ul data-type=\"taskList\">")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</ul>")
}
"taskItem" -> {
val checked = (node["attrs"] as? Map<*, *>)?.get("checked") as? Boolean ?: false
val checkedAttr = if (checked) " checked" else ""
sb.append("<li data-type=\"taskItem\" data-checked=\"$checked\">")
sb.append("<label><input type=\"checkbox\"$checkedAttr></label>")
content?.forEach { child ->
if (child is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
sb.append(nodeToHtml(child as Map<String, Any?>))
}
}
sb.append("</li>")
}
"codeBlock" -> { "codeBlock" -> {
sb.append("<pre><code>") sb.append("<pre><code>")
content?.forEach { child -> content?.forEach { child ->

View File

@@ -0,0 +1,57 @@
<?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="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvDialogSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="12dp" />
<EditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="48dp"
android:hint="@string/search_cached_pages"
android:inputType="text"
android:textSize="15sp"
android:background="@drawable/bg_search"
android:drawableStart="@drawable/ic_search"
android:drawablePadding="10dp"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:maxLines="1"
android:layout_marginBottom="8dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCachedPages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="14sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:padding="32dp"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,45 @@
<?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="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvPageIcon"
android:layout_width="40dp"
android:layout_height="40dp"
android:gravity="center"
android:textSize="22sp"
android:text="📄" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvPageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvPageDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>

View File

@@ -128,4 +128,6 @@
<string name="clean_confirm">¿Eliminar todas las páginas descargadas? Las borradores locales se conservarán.</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="cache_cleaned">Caché limpiada: %d páginas eliminadas</string>
<string name="cached_count">Páginas en caché: %d</string> <string name="cached_count">Páginas en caché: %d</string>
<string name="search_cached_pages">Buscar páginas guardadas…</string>
<string name="no_cached_pages_found">No se encontraron páginas</string>
</resources> </resources>