3 Commits

Author SHA1 Message Date
ae57a0bf09 v1.0.4: Fix tiptapToMarkdown JsonElement type casting
- tiptapToMarkdown used JsonObject.asMap() which returns Map<String, JsonElement>
- All 'as? String' casts silently failed (JsonPrimitive != String), returning empty
- Now uses Gson TypeToken<Map<String, Any?>> for proper raw type deserialization
- contentToMarkdown now handles Map types directly without serialize roundtrip
2026-07-27 17:32:47 -06:00
7be826f9ba v1.0.3: Fix TipTap JSON content conversion for editor display
- Replace all TypeToken usage with JsonParser/gson.fromJson (fixes R8/ProGuard crash)
- Add parseJsonArray/parseJsonObject helpers for API response parsing
- Fix contentToMarkdown to properly convert TipTap JSON objects to Markdown
  using tiptapToMarkdown instead of returning raw JSON
- Fix API endpoints to handle unwrapped response formats
- Add ProGuard rules for Gson TypeToken preservation
- Redirect build dir to /tmp for Samba compatibility
2026-07-27 17:25:40 -06:00
bc6ed08648 feat: add release build config, signing support and v1.0.1 2026-07-26 22:02:06 -06:00
12 changed files with 220 additions and 100 deletions

4
.gitignore vendored
View File

@@ -55,3 +55,7 @@ DocmostApp.md
# App build # App build
app/build/ app/build/
# Signing
*.jks
keystore.properties

View File

@@ -1,9 +1,17 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
kotlin("kapt") kotlin("kapt")
} }
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android { android {
namespace = "com.docmost.app" namespace = "com.docmost.app"
compileSdk = 34 compileSdk = 34
@@ -12,14 +20,24 @@ android {
applicationId = "com.docmost.app" applicationId = "com.docmost.app"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 5
versionName = "1.0" versionName = "1.0.4"
}
signingConfigs {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile", "release.jks"))
storePassword = keystoreProperties.getProperty("storePassword", "")
keyAlias = keystoreProperties.getProperty("keyAlias", "")
keyPassword = keystoreProperties.getProperty("keyPassword", "")
}
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -38,6 +56,7 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true
} }
} }

View File

@@ -2,5 +2,14 @@
-keepattributes Signature -keepattributes Signature
-keepattributes *Annotation* -keepattributes *Annotation*
-keep class com.docmost.app.api.** { *; } -keep class com.docmost.app.api.** { *; }
-keep class com.docmost.app.data.** { *; }
-dontwarn okhttp3.** -dontwarn okhttp3.**
-dontwarn okio.** -dontwarn okio.**
# Gson - preserve TypeToken anonymous subclasses and generic signatures
-keep class com.google.gson.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.TypeAdapter { *; }
-keepattributes Signature
-keepattributes RuntimeVisibleAnnotations
-keep class sun.misc.Unsafe { *; }

View File

@@ -3,7 +3,6 @@ package com.docmost.app.api
import android.util.Log import android.util.Log
import com.docmost.app.BuildConfig import com.docmost.app.BuildConfig
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import android.content.Context import android.content.Context
@@ -119,17 +118,55 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getSpaces(): List<Space> = withContext(Dispatchers.IO) { suspend fun getSpaces(): List<Space> = withContext(Dispatchers.IO) {
val json = post("/api/spaces") val json = post("/api/spaces")
val type = object : TypeToken<DocmostResponse<SpacesData>>() {}.type if (BuildConfig.DEBUG) Log.d("DocmostAPI", "Spaces response: $json")
val response = gson.fromJson<DocmostResponse<SpacesData>>(json, type) val element = com.google.gson.JsonParser.parseString(json)
val items = response.data?.items ?: emptyList() if (element.isJsonArray) {
items element.asJsonArray.map { gson.fromJson(it, Space::class.java) }
} else {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) {
val items = data.asJsonObject.get("items")
if (items != null && items.isJsonArray) {
items.asJsonArray.map { gson.fromJson(it, Space::class.java) }
} else emptyList()
} else if (data != null && data.isJsonArray) {
data.asJsonArray.map { gson.fromJson(it, Space::class.java) }
} else emptyList()
}
} }
suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List<Page> = withContext(Dispatchers.IO) { suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List<Page> = withContext(Dispatchers.IO) {
val json = post("/api/pages/sidebar-pages", SidebarPagesRequest(spaceId = spaceId, pageId = pageId)) val json = post("/api/pages/sidebar-pages", SidebarPagesRequest(spaceId = spaceId, pageId = pageId))
val type = object : TypeToken<DocmostResponse<PagesData>>() {}.type parseJsonArray<Page>(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<PagesData>>(json, type) }
response.data?.items ?: emptyList()
private fun <T> parseJsonArray(json: String, mapper: (com.google.gson.JsonElement) -> T): List<T> {
val element = com.google.gson.JsonParser.parseString(json)
if (element.isJsonArray) {
return element.asJsonArray.map(mapper)
}
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) {
val items = data.asJsonObject.get("items")
if (items != null && items.isJsonArray) {
return items.asJsonArray.map(mapper)
}
} else if (data != null && data.isJsonArray) {
return data.asJsonArray.map(mapper)
}
return emptyList()
}
private fun <T> parseJsonObject(json: String, mapper: (String) -> T): T? {
val element = com.google.gson.JsonParser.parseString(json)
val objJson = if (element.isJsonObject) {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) data.toString() else json
} else json
return mapper(objJson)
} }
private suspend fun fetchPageChildren(spaceId: String, parentPage: Page, allPages: MutableList<Page>) { private suspend fun fetchPageChildren(spaceId: String, parentPage: Page, allPages: MutableList<Page>) {
@@ -156,9 +193,16 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getPageInfo(pageId: String, format: String = "json"): Page = withContext(Dispatchers.IO) { suspend fun getPageInfo(pageId: String, format: String = "json"): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/info", PageInfoRequest(pageId, format)) val json = post("/api/pages/info", PageInfoRequest(pageId, format))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type Log.d("DocmostAPI", "PageInfo response: $json")
val response = gson.fromJson<DocmostResponse<Page>>(json, type) val element = com.google.gson.JsonParser.parseString(json)
response.data ?: throw IOException("Page not found in response") val pageJson = if (element.isJsonObject) {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) data.toString() else json
} else json
Log.d("DocmostAPI", "PageInfo parsed: $pageJson")
val page = gson.fromJson(pageJson, Page::class.java)
page ?: throw IOException("Page not found in response")
} }
suspend fun getPageHtml(pageId: String): String = withContext(Dispatchers.IO) { suspend fun getPageHtml(pageId: String): String = withContext(Dispatchers.IO) {
@@ -181,9 +225,8 @@ class DocmostApi(private val baseUrl: String) {
description = description description = description
) )
) )
val type = object : TypeToken<DocmostResponse<Space>>() {}.type parseJsonObject(json) { gson.fromJson(it, Space::class.java) }
val response = gson.fromJson<DocmostResponse<Space>>(json, type) ?: throw IOException("Space update response missing data")
response.data ?: throw IOException("Space update response missing data")
} }
suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String = suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String =
@@ -235,38 +278,32 @@ class DocmostApi(private val baseUrl: String) {
operation = "replace" operation = "replace"
) )
) )
val type = object : TypeToken<DocmostResponse<Page>>() {}.type parseJsonObject(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<Page>>(json, type) ?: throw IOException("Update response missing data")
response.data ?: throw IOException("Update response missing data")
} }
suspend fun getCurrentUser(): User = withContext(Dispatchers.IO) { suspend fun getCurrentUser(): User = withContext(Dispatchers.IO) {
val json = post("/api/users/me") val json = post("/api/users/me")
val type = object : TypeToken<DocmostResponse<UserInfoResponse>>() {}.type val user = parseJsonObject(json) { gson.fromJson(it, User::class.java) }
val response = gson.fromJson<DocmostResponse<UserInfoResponse>>(json, type) user ?: throw IOException("User not found in response")
response.data?.user ?: throw IOException("User not found in response")
} }
suspend fun updateUser(name: String?): User = withContext(Dispatchers.IO) { suspend fun updateUser(name: String?): User = withContext(Dispatchers.IO) {
val json = post("/api/users/update", UserUpdateRequest(name = name)) val json = post("/api/users/update", UserUpdateRequest(name = name))
val type = object : TypeToken<DocmostResponse<User>>() {}.type parseJsonObject(json) { gson.fromJson(it, User::class.java) }
val response = gson.fromJson<DocmostResponse<User>>(json, type) ?: throw IOException("Update user response missing data")
response.data ?: throw IOException("Update user response missing data")
} }
suspend fun searchPages(query: String): List<SearchResult> = withContext(Dispatchers.IO) { suspend fun searchPages(query: String): List<SearchResult> = withContext(Dispatchers.IO) {
val json = post("/api/search", SearchRequest(query = query)) val json = post("/api/search", SearchRequest(query = query))
val type = object : TypeToken<DocmostResponse<SearchResultsResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, SearchResult::class.java) }
val response = gson.fromJson<DocmostResponse<SearchResultsResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createSpace(name: String, slug: String, description: String? = null): Space = suspend fun createSpace(name: String, slug: String, description: String? = null): Space =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val json = post("/api/spaces/create", CreateSpaceRequest(name = name, slug = slug, description = description)) val json = post("/api/spaces/create", CreateSpaceRequest(name = name, slug = slug, description = description))
val type = object : TypeToken<DocmostResponse<Space>>() {}.type parseJsonObject(json) { gson.fromJson(it, Space::class.java) }
val response = gson.fromJson<DocmostResponse<Space>>(json, type) ?: throw IOException("Create space response missing data")
response.data ?: throw IOException("Create space response missing data")
} }
suspend fun removeAvatar() = withContext(Dispatchers.IO) { suspend fun removeAvatar() = withContext(Dispatchers.IO) {
@@ -306,16 +343,13 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getRecentPages(): List<Page> = withContext(Dispatchers.IO) { suspend fun getRecentPages(): List<Page> = withContext(Dispatchers.IO) {
val json = post("/api/pages/recent", RecentPageRequest()) val json = post("/api/pages/recent", RecentPageRequest())
val type = object : TypeToken<DocmostResponse<RecentPagesResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<RecentPagesResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createPage(title: String, spaceId: String): Page = withContext(Dispatchers.IO) { suspend fun createPage(title: String, spaceId: String): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/create", CreatePageRequest(title = title, spaceId = spaceId)) val json = post("/api/pages/create", CreatePageRequest(title = title, spaceId = spaceId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type parseJsonObject(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<Page>>(json, type) ?: throw IOException("Create page response missing data")
response.data ?: throw IOException("Create page response missing data")
} }
suspend fun deletePage(pageId: String) = withContext(Dispatchers.IO) { suspend fun deletePage(pageId: String) = withContext(Dispatchers.IO) {
@@ -328,16 +362,13 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getShares(): List<Share> = withContext(Dispatchers.IO) { suspend fun getShares(): List<Share> = withContext(Dispatchers.IO) {
val json = post("/api/shares/", mapOf("limit" to 50)) val json = post("/api/shares/", mapOf("limit" to 50))
val type = object : TypeToken<DocmostResponse<SharesData>>() {}.type parseJsonArray(json) { gson.fromJson(it, Share::class.java) }
val response = gson.fromJson<DocmostResponse<SharesData>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createShare(pageId: String): Share = withContext(Dispatchers.IO) { suspend fun createShare(pageId: String): Share = withContext(Dispatchers.IO) {
val json = post("/api/shares/create", CreateShareDto(pageId = pageId)) val json = post("/api/shares/create", CreateShareDto(pageId = pageId))
val type = object : TypeToken<DocmostResponse<Share>>() {}.type parseJsonObject(json) { gson.fromJson(it, Share::class.java) }
val response = gson.fromJson<DocmostResponse<Share>>(json, type) ?: throw IOException("Create share response missing data")
response.data ?: throw IOException("Create share response missing data")
} }
suspend fun deleteShare(shareId: String) = withContext(Dispatchers.IO) { suspend fun deleteShare(shareId: String) = withContext(Dispatchers.IO) {
@@ -346,23 +377,19 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getComments(pageId: String): List<Comment> = withContext(Dispatchers.IO) { suspend fun getComments(pageId: String): List<Comment> = withContext(Dispatchers.IO) {
val json = post("/api/comments/", mapOf("pageId" to pageId)) val json = post("/api/comments/", mapOf("pageId" to pageId))
val type = object : TypeToken<DocmostResponse<CommentsData>>() {}.type parseJsonArray(json) { gson.fromJson(it, Comment::class.java) }
val response = gson.fromJson<DocmostResponse<CommentsData>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createComment(pageId: String, content: String): Comment = withContext(Dispatchers.IO) { suspend fun createComment(pageId: String, content: String): Comment = withContext(Dispatchers.IO) {
val json = post("/api/comments/create", CreateCommentDto(pageId = pageId, content = content)) val json = post("/api/comments/create", CreateCommentDto(pageId = pageId, content = content))
val type = object : TypeToken<DocmostResponse<Comment>>() {}.type parseJsonObject(json) { gson.fromJson(it, Comment::class.java) }
val response = gson.fromJson<DocmostResponse<Comment>>(json, type) ?: throw IOException("Create comment response missing data")
response.data ?: throw IOException("Create comment response missing data")
} }
suspend fun updateComment(commentId: String, content: String): Comment = withContext(Dispatchers.IO) { suspend fun updateComment(commentId: String, content: String): Comment = withContext(Dispatchers.IO) {
val json = post("/api/comments/update", UpdateCommentDto(commentId = commentId, content = content)) val json = post("/api/comments/update", UpdateCommentDto(commentId = commentId, content = content))
val type = object : TypeToken<DocmostResponse<Comment>>() {}.type parseJsonObject(json) { gson.fromJson(it, Comment::class.java) }
val response = gson.fromJson<DocmostResponse<Comment>>(json, type) ?: throw IOException("Update comment response missing data")
response.data ?: throw IOException("Update comment response missing data")
} }
suspend fun deleteComment(commentId: String) = withContext(Dispatchers.IO) { suspend fun deleteComment(commentId: String) = withContext(Dispatchers.IO) {
@@ -371,9 +398,8 @@ class DocmostApi(private val baseUrl: String) {
suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page = withContext(Dispatchers.IO) { suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/duplicate", DuplicatePageRequest(pageId = pageId, spaceId = targetSpaceId)) val json = post("/api/pages/duplicate", DuplicatePageRequest(pageId = pageId, spaceId = targetSpaceId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type parseJsonObject(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<Page>>(json, type) ?: throw IOException("Duplicate response missing data")
response.data ?: throw IOException("Duplicate response missing data")
} }
suspend fun movePageToSpace(pageId: String, spaceId: String) = withContext(Dispatchers.IO) { suspend fun movePageToSpace(pageId: String, spaceId: String) = withContext(Dispatchers.IO) {
@@ -391,31 +417,33 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getPageHistory(pageId: String): List<PageHistory> = withContext(Dispatchers.IO) { suspend fun getPageHistory(pageId: String): List<PageHistory> = withContext(Dispatchers.IO) {
val json = post("/api/pages/history", PageHistoryRequest(pageId = pageId)) val json = post("/api/pages/history", PageHistoryRequest(pageId = pageId))
val type = object : TypeToken<DocmostResponse<PageHistoryResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, PageHistory::class.java) }
val response = gson.fromJson<DocmostResponse<PageHistoryResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory = withContext(Dispatchers.IO) { suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory = withContext(Dispatchers.IO) {
val json = post("/api/pages/history/info", mapOf("pageId" to pageId, "historyId" to historyId)) val json = post("/api/pages/history/info", mapOf("pageId" to pageId, "historyId" to historyId))
val type = object : TypeToken<DocmostResponse<PageHistory>>() {}.type parseJsonObject(json) { gson.fromJson(it, PageHistory::class.java) }
val response = gson.fromJson<DocmostResponse<PageHistory>>(json, type) ?: throw IOException("Page history not found in response")
response.data ?: throw IOException("Page history not found in response")
} }
suspend fun getPageContent(pageId: String): PageContent = withContext(Dispatchers.IO) { suspend fun getPageContent(pageId: String): PageContent = withContext(Dispatchers.IO) {
val json = post("/api/pages/info", PageInfoRequest(pageId = pageId)) val json = post("/api/pages/info", PageInfoRequest(pageId = pageId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type Log.d("DocmostAPI", "PageContent response: $json")
val response = gson.fromJson<DocmostResponse<Page>>(json, type) val element = com.google.gson.JsonParser.parseString(json)
val page = response.data ?: throw IOException("Page not found in response") val pageJson = if (element.isJsonObject) {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) data.toString() else json
} else json
Log.d("DocmostAPI", "PageContent parsed: $pageJson")
val page = gson.fromJson(pageJson, Page::class.java)
page ?: throw IOException("Page not found in response")
PageContent(source = page.source ?: "") PageContent(source = page.source ?: "")
} }
suspend fun getDeletedPages(spaceId: String): List<DeletedPage> = withContext(Dispatchers.IO) { suspend fun getDeletedPages(spaceId: String): List<DeletedPage> = withContext(Dispatchers.IO) {
val json = post("/api/pages/trash", DeletedPagesRequest(spaceId = spaceId)) val json = post("/api/pages/trash", DeletedPagesRequest(spaceId = spaceId))
val type = object : TypeToken<DocmostResponse<DeletedPagesResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, DeletedPage::class.java) }
val response = gson.fromJson<DocmostResponse<DeletedPagesResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun restorePage(pageId: String, spaceId: String? = null) = withContext(Dispatchers.IO) { suspend fun restorePage(pageId: String, spaceId: String? = null) = withContext(Dispatchers.IO) {
@@ -467,9 +495,7 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getPageAttachments(pageId: String): List<Attachment> = withContext(Dispatchers.IO) { suspend fun getPageAttachments(pageId: String): List<Attachment> = withContext(Dispatchers.IO) {
val json = post("/api/attachments/page", mapOf("pageId" to pageId)) val json = post("/api/attachments/page", mapOf("pageId" to pageId))
val type = object : TypeToken<DocmostResponse<AttachmentsResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, Attachment::class.java) }
val response = gson.fromJson<DocmostResponse<AttachmentsResponse>>(json, type)
response.data?.items ?: emptyList()
} }
private fun getFileNameFromUri(context: android.content.Context, uri: android.net.Uri): String? { private fun getFileNameFromUri(context: android.content.Context, uri: android.net.Uri): String? {

View File

@@ -4,7 +4,7 @@ import android.content.Context
import com.docmost.app.api.Page import com.docmost.app.api.Page
import com.docmost.app.BuildConfig import com.docmost.app.BuildConfig
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.JsonParser
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@@ -123,8 +123,8 @@ class PageCacheManager(private val context: Context) {
fun getCachedPages(): List<CachedPageMeta> { fun getCachedPages(): List<CachedPageMeta> {
return try { return try {
if (!indexFile.exists()) return emptyList() if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedPageMeta>>() {}.type val array = JsonParser.parseString(indexFile.readText()).asJsonArray
gson.fromJson(indexFile.readText(), type) ?: emptyList() array.map { gson.fromJson(it, CachedPageMeta::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }
@@ -153,8 +153,8 @@ class PageCacheManager(private val context: Context) {
return try { return try {
val file = File(spacePagesDir, "$spaceId.json") val file = File(spacePagesDir, "$spaceId.json")
if (!file.exists()) return null if (!file.exists()) return null
val type = object : TypeToken<List<Page>>() {}.type val array = JsonParser.parseString(file.readText()).asJsonArray
gson.fromJson(file.readText(), type) array.map { gson.fromJson(it, Page::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -178,8 +178,8 @@ class PageCacheManager(private val context: Context) {
private fun getIndex(): List<CachedPageMeta> { private fun getIndex(): List<CachedPageMeta> {
return try { return try {
if (!indexFile.exists()) return emptyList() if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedPageMeta>>() {}.type val array = JsonParser.parseString(indexFile.readText()).asJsonArray
gson.fromJson(indexFile.readText(), type) ?: emptyList() array.map { gson.fromJson(it, CachedPageMeta::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View File

@@ -5,6 +5,7 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys import androidx.security.crypto.MasterKeys
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonParser
data class Credentials( data class Credentials(
@@ -110,8 +111,8 @@ class SessionManager(private val context: Context) {
null null
} ?: return emptyList() } ?: return emptyList()
return try { return try {
val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, Credentials::class.java).type val array = JsonParser.parseString(json).asJsonArray
gson.fromJson(json, type) array.map { gson.fromJson(it, Credentials::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View File

@@ -4,7 +4,7 @@ import android.content.Context
import com.docmost.app.api.Space import com.docmost.app.api.Space
import com.docmost.app.BuildConfig import com.docmost.app.BuildConfig
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.JsonParser
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@@ -119,8 +119,8 @@ class SpaceCacheManager(private val context: Context) {
fun getIndex(): List<CachedSpaceMeta> { fun getIndex(): List<CachedSpaceMeta> {
return try { return try {
if (!indexFile.exists()) return emptyList() if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedSpaceMeta>>() {}.type val array = JsonParser.parseString(indexFile.readText()).asJsonArray
gson.fromJson(indexFile.readText(), type) ?: emptyList() array.map { gson.fromJson(it, CachedSpaceMeta::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View File

@@ -6,7 +6,6 @@ import androidx.room.PrimaryKey
import androidx.room.TypeConverter import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
enum class OperationType { enum class OperationType {
CREATE_SPACE, CREATE_SPACE,

View File

@@ -59,7 +59,6 @@ import com.docmost.app.utils.NetworkConnectivityObserver
import com.docmost.app.utils.NetworkStatus import com.docmost.app.utils.NetworkStatus
import com.docmost.app.databinding.ActivityPageEditorBinding import com.docmost.app.databinding.ActivityPageEditorBinding
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -1878,8 +1877,8 @@ class PageEditorActivity : AppCompatActivity() {
private fun tiptapToMarkdown(tiptapJson: String): String { private fun tiptapToMarkdown(tiptapJson: String): String {
return try { return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type val type = object : com.google.gson.reflect.TypeToken<Map<String, Any?>>() {}.type
val doc = gson.fromJson<Map<String, Any?>>(tiptapJson, mapType) val doc: Map<String, Any?> = gson.fromJson(tiptapJson, type)
extractText(doc) extractText(doc)
} catch (e: Exception) { } catch (e: Exception) {
tiptapJson tiptapJson
@@ -2090,11 +2089,13 @@ class PageEditorActivity : AppCompatActivity() {
canEdit = page.permissions?.canEdit ?: true canEdit = page.permissions?.canEdit ?: true
currentPage = page currentPage = page
android.util.Log.d("PageEditor", "renderPageContent: page.content type=${page.content?.let { it::class.java.simpleName } ?: "null"}")
if (!canEdit) { if (!canEdit) {
loadReadOnlyPreview(page) loadReadOnlyPreview(page)
} else { } else {
val markdown = contentToMarkdown(page.content) val markdown = contentToMarkdown(page.content)
if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Markdown content length: ${markdown.length}") android.util.Log.d("PageEditor", "Markdown content length: ${markdown.length}")
currentPageContent = markdown currentPageContent = markdown
originalContent = markdown originalContent = markdown
binding.etTitle.setText(page.title) binding.etTitle.setText(page.title)
@@ -2484,15 +2485,68 @@ class PageEditorActivity : AppCompatActivity() {
private fun contentToMarkdown(content: Any?): String { private fun contentToMarkdown(content: Any?): String {
if (content == null) return "" if (content == null) return ""
if (content is String) return content if (content is String) {
return if (content.trimStart().startsWith("<")) {
htmlToMarkdown(content)
} else {
content
}
}
if (content is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
return try {
extractText(content as Map<String, Any?>)
} catch (e: Exception) {
android.util.Log.e("PageEditor", "contentToMarkdown map error: ${e.message}")
""
}
}
return try { return try {
val json = gson.toJson(content) val json = gson.toJson(content)
tiptapToMarkdown(json) if (json.isNotEmpty() && json != "null") {
tiptapToMarkdown(json)
} else {
""
}
} catch (e: Exception) { } catch (e: Exception) {
content.toString() android.util.Log.e("PageEditor", "contentToMarkdown error: ${e.message}")
""
} }
} }
private fun htmlToMarkdown(html: String): String {
return html
.replace(Regex("<h1[^>]*>(.*?)</h1>", RegexOption.DOT_MATCHES_ALL)) { "# ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h2[^>]*>(.*?)</h2>", RegexOption.DOT_MATCHES_ALL)) { "## ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h3[^>]*>(.*?)</h3>", RegexOption.DOT_MATCHES_ALL)) { "### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h4[^>]*>(.*?)</h4>", RegexOption.DOT_MATCHES_ALL)) { "#### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h5[^>]*>(.*?)</h5>", RegexOption.DOT_MATCHES_ALL)) { "##### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h6[^>]*>(.*?)</h6>", RegexOption.DOT_MATCHES_ALL)) { "###### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<strong[^>]*>(.*?)</strong>", RegexOption.DOT_MATCHES_ALL)) { "**${it.groupValues[1]}**" }
.replace(Regex("<b[^>]*>(.*?)</b>", RegexOption.DOT_MATCHES_ALL)) { "**${it.groupValues[1]}**" }
.replace(Regex("<em[^>]*>(.*?)</em>", RegexOption.DOT_MATCHES_ALL)) { "*${it.groupValues[1]}*" }
.replace(Regex("<i[^>]*>(.*?)</i>", RegexOption.DOT_MATCHES_ALL)) { "*${it.groupValues[1]}*" }
.replace(Regex("<del[^>]*>(.*?)</del>", RegexOption.DOT_MATCHES_ALL)) { "~~${it.groupValues[1]}~~" }
.replace(Regex("<code[^>]*>(.*?)</code>", RegexOption.DOT_MATCHES_ALL)) { "`${it.groupValues[1]}`" }
.replace(Regex("<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>", RegexOption.DOT_MATCHES_ALL)) { "[${it.groupValues[2]}](${it.groupValues[1]})" }
.replace(Regex("<img[^>]*src=\"([^\"]+)\"[^>]*alt=\"([^\"]*)\"[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "![${it.groupValues[2]}](${it.groupValues[1]})" }
.replace(Regex("<img[^>]*alt=\"([^\"]*)\"[^>]*src=\"([^\"]+)\"[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "![${it.groupValues[1]}](${it.groupValues[2]})" }
.replace(Regex("<li[^>]*>(.*?)</li>", RegexOption.DOT_MATCHES_ALL)) { "- ${it.groupValues[1].replace(Regex("<[^>]*>"), "").trim()}\n" }
.replace(Regex("<blockquote[^>]*>(.*?)</blockquote>", RegexOption.DOT_MATCHES_ALL)) { "> ${it.groupValues[1].replace(Regex("<[^>]*>"), "").trim()}\n\n" }
.replace(Regex("<pre[^>]*><code[^>]*>(.*?)</code></pre>", RegexOption.DOT_MATCHES_ALL)) { "```\n${it.groupValues[1].replace(Regex("<[^>]*>"), "")}\n```\n\n" }
.replace(Regex("<p[^>]*>(.*?)</p>", RegexOption.DOT_MATCHES_ALL)) { "${it.groupValues[1].replace(Regex("<[^>]*>"), "").trim()}\n\n" }
.replace(Regex("<br[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "\n" }
.replace(Regex("<hr[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "---\n\n" }
.replace(Regex("<[^>]*>"), "")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&nbsp;", " ")
.replace(Regex("\n{3,}"), "\n\n")
.trim()
}
private fun markdownToTipTap(markdown: String): String { private fun markdownToTipTap(markdown: String): String {
// This function converts Markdown to TipTap JSON format that Docmost expects // This function converts Markdown to TipTap JSON format that Docmost expects
// It handles attachments specially to preserve the attachment type // It handles attachments specially to preserve the attachment type

View File

@@ -54,7 +54,6 @@ import com.docmost.app.utils.NetworkConnectivityObserver
import com.docmost.app.utils.NetworkStatus import com.docmost.app.utils.NetworkStatus
import com.docmost.app.databinding.ActivitySpacesBinding import com.docmost.app.databinding.ActivitySpacesBinding
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
@@ -909,8 +908,8 @@ class SpacesActivity : AppCompatActivity() {
private fun loadRecentCache(): List<Page>? { private fun loadRecentCache(): List<Page>? {
try { try {
val json = prefs.getString("recent_cache", null) ?: return null val json = prefs.getString("recent_cache", null) ?: return null
val type = object : TypeToken<List<Page>>() {}.type val array = com.google.gson.JsonParser.parseString(json).asJsonArray
return gson.fromJson(json, type) return array.map { gson.fromJson(it, Page::class.java) }
} catch (_: Exception) { return null } } catch (_: Exception) { return null }
} }

View File

@@ -1,16 +1,16 @@
package com.docmost.app.utils package com.docmost.app.utils
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.JsonObject
object ContentConverter { object ContentConverter {
private val gson = Gson() private val gson = Gson()
fun tipTapJsonToHtml(tipTapJson: String): String { fun tipTapJsonToHtml(tipTapJson: String): String {
return try { return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type val doc = gson.fromJson(tipTapJson, JsonObject::class.java).asMap()
val doc = gson.fromJson<Map<String, Any?>>(tipTapJson, mapType) @Suppress("UNCHECKED_CAST")
nodeToHtml(doc) nodeToHtml(doc as Map<String, Any?>)
} catch (e: Exception) { } catch (e: Exception) {
tipTapJson tipTapJson
} }
@@ -18,9 +18,9 @@ object ContentConverter {
fun tipTapToHtml(tipTapJson: String): String { fun tipTapToHtml(tipTapJson: String): String {
return try { return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type val doc = gson.fromJson(tipTapJson, JsonObject::class.java).asMap()
val doc = gson.fromJson<Map<String, Any?>>(tipTapJson, mapType) @Suppress("UNCHECKED_CAST")
nodeToHtml(doc) nodeToHtml(doc as Map<String, Any?>)
} catch (e: Exception) { } catch (e: Exception) {
tipTapJson tipTapJson
} }

View File

@@ -14,3 +14,12 @@ dependencyResolutionManagement {
rootProject.name = "Docmost" rootProject.name = "Docmost"
include(":app") include(":app")
val localBuildDir = "/tmp/android-build/Docmost"
gradle.projectsLoaded {
rootProject.layout.buildDirectory.set(file(localBuildDir))
rootProject.subprojects.forEach { project ->
project.layout.buildDirectory.set(file("$localBuildDir/${project.name}"))
}
}